Files
SillyTavern/tests/frontend/MacroEnvBuilder.e2e.js
Copilot b5a1d227fd Implement {{charFirstMessage}} / {{greeting}} macro with alternate greeting indexing and substitution (#5220)
* Initial plan

* Implement {{firstMessage}} / {{greeting}} macro for character's first message

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

* Simplify firstMessage resolver to use || instead of ?? for consistency

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

* Remove {{firstMessage}} alias, add 0-based greeting index for alternate greetings

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

* Add alternateGreetings to env, apply substitution to greetings

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

* Remove legacy greeting macro, keep only new engine implementation

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>
Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
2026-03-01 17:44:29 +02:00

500 lines
20 KiB
JavaScript

import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
test.describe('MacroEnvBuilder', () => {
test.beforeEach(testSetup.awaitST);
test('builds names from overrides without relying on globals', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: 'ignored',
name1Override: 'UserOverride',
name2Override: 'CharOverride',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
user: env.names?.user,
char: env.names?.char,
};
});
expect(result).toEqual({
user: 'UserOverride',
char: 'CharOverride',
});
});
test('falls back to global name1/name2 when overrides are not provided', async ({ page }) => {
const result = await page.evaluate(async () => {
const script = await import('./script.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
globalUser: script.name1,
globalChar: script.name2,
envUser: env.names?.user,
envChar: env.names?.char,
};
});
expect(result.envUser).toBe(result.globalUser);
expect(result.envChar).toBe(result.globalChar);
});
test('does not populate character fields when replaceCharacterCard is false', async ({ page }) => {
const keys = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
replaceCharacterCard: false,
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return Object.keys(env.character || {});
});
expect(keys).toEqual([]);
});
test('populates character fields when replaceCharacterCard is true', async ({ page }) => {
const keys = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
replaceCharacterCard: true,
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return Object.keys(env.character || {});
});
// We do not assert on concrete values, only that the known keys exist
expect(keys).toEqual(expect.arrayContaining([
'charPrompt',
'charInstruction',
'description',
'personality',
'scenario',
'persona',
'mesExamplesRaw',
'version',
'charDepthPrompt',
'creatorNotes',
'firstMessage',
'alternateGreetings',
]));
});
test('wraps original string into a one-shot helper function', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
original: 'ORIGINAL_VALUE',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
const hasFn = typeof env.functions?.original === 'function';
const first = hasFn ? env.functions.original() : null;
const second = hasFn ? env.functions.original() : null;
return { hasFn, first, second };
});
expect(result).toEqual({
hasFn: true,
first: 'ORIGINAL_VALUE',
second: '',
});
});
test('does not expose original helper when original is not a string', async ({ page }) => {
const hasFn = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
original: undefined,
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return typeof env.functions?.original === 'function';
});
expect(hasFn).toBe(false);
});
test('uses groupOverride string for all group-related name fields', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
groupOverride: 'Group One, Group Two',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
group: env.names?.group,
groupNotMuted: env.names?.groupNotMuted,
notChar: env.names?.notChar,
};
});
expect(result).toEqual({
group: 'Group One, Group Two',
groupNotMuted: 'Group One, Group Two',
notChar: 'Group One, Group Two',
});
});
test('uses solo-chat semantics when no group is selected', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const groupChats = await import('./scripts/group-chats.js');
// Ensure we are in a solo-chat like state for this test
if (typeof groupChats.resetSelectedGroup === 'function') {
groupChats.resetSelectedGroup();
}
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
name1Override: 'UserSolo',
name2Override: 'CharSolo',
groupOverride: undefined,
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
group: env.names?.group,
groupNotMuted: env.names?.groupNotMuted,
notChar: env.names?.notChar,
};
});
expect(result).toEqual({
group: 'CharSolo',
groupNotMuted: 'CharSolo',
notChar: 'UserSolo',
});
});
test.describe('Dynamic macros in env', () => {
test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {
const dynamicMacros = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
simple: 'value',
number: 42,
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return env.dynamicMacros;
});
expect(dynamicMacros.simple).toBe('value');
expect(dynamicMacros.number).toBe(42);
});
test('normalizes dynamic macro keys to lowercase', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
MyMacro: 'value1',
UPPERCASE: 'value2',
lowercase: 'value3',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
hasLower: 'mymacro' in env.dynamicMacros,
hasUpper: 'uppercase' in env.dynamicMacros,
hasLowercase: 'lowercase' in env.dynamicMacros,
value1: env.dynamicMacros['mymacro'],
value2: env.dynamicMacros['uppercase'],
value3: env.dynamicMacros['lowercase'],
};
});
expect(result.hasLower).toBe(true);
expect(result.hasUpper).toBe(true);
expect(result.hasLowercase).toBe(true);
expect(result.value1).toBe('value1');
expect(result.value2).toBe('value2');
expect(result.value3).toBe('value3');
});
test('accepts string values for dynamic macros', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
greeting: 'Hello, World!',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
value: env.dynamicMacros['greeting'],
type: typeof env.dynamicMacros['greeting'],
};
});
expect(result.value).toBe('Hello, World!');
expect(result.type).toBe('string');
});
test('accepts handler functions for dynamic macros', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
dyn: () => 'handler result',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
const storedValue = env.dynamicMacros['dyn'];
return {
isFunction: typeof storedValue === 'function',
callResult: typeof storedValue === 'function' ? storedValue() : null,
};
});
expect(result.isFunction).toBe(true);
expect(result.callResult).toBe('handler result');
});
test('accepts MacroDefinitionOptions objects for dynamic macros', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
greet: {
description: 'A greeting macro',
unnamedArgs: [{ name: 'name' }],
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
const storedValue = env.dynamicMacros['greet'];
return {
isObject: typeof storedValue === 'object' && storedValue !== null,
hasHandler: typeof storedValue?.handler === 'function',
hasDescription: typeof storedValue?.description === 'string',
hasUnnamedArgs: Array.isArray(storedValue?.unnamedArgs),
};
});
expect(result.isObject).toBe(true);
expect(result.hasHandler).toBe(true);
expect(result.hasDescription).toBe(true);
expect(result.hasUnnamedArgs).toBe(true);
});
test('supports mixed dynamic macro value types', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: {
stringVal: 'direct string',
funcVal: () => 'from function',
optionsVal: {
handler: () => 'from options',
unnamedArgs: 1,
},
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
stringType: typeof env.dynamicMacros['stringval'],
funcType: typeof env.dynamicMacros['funcval'],
optionsType: typeof env.dynamicMacros['optionsval'],
optionsHasHandler: typeof env.dynamicMacros['optionsval']?.handler === 'function',
};
});
expect(result.stringType).toBe('string');
expect(result.funcType).toBe('function');
expect(result.optionsType).toBe('object');
expect(result.optionsHasHandler).toBe(true);
});
test('handles null dynamicMacros gracefully', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
dynamicMacros: null,
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
hasDynamicMacros: 'dynamicMacros' in env,
isEmpty: Object.keys(env.dynamicMacros).length === 0,
};
});
expect(result.hasDynamicMacros).toBe(true);
expect(result.isEmpty).toBe(true);
});
test('handles undefined dynamicMacros gracefully', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
const ctx = {
content: '',
// dynamicMacros not provided
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
hasDynamicMacros: 'dynamicMacros' in env,
isEmpty: Object.keys(env.dynamicMacros).length === 0,
};
});
expect(result.hasDynamicMacros).toBe(true);
expect(result.isEmpty).toBe(true);
});
});
test('sets system.model field from getGeneratingModel helper', async ({ page }) => {
const model = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return env.system?.model;
});
expect(typeof model === 'string' || model === undefined).toBe(true);
});
test('applies providers in the expected order buckets', async ({ page }) => {
const order = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder, env_provider_order } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
MacroEnvBuilder.registerProvider((env) => {
env.extra.order = [...(env.extra.order || []), 'EARLY'];
}, env_provider_order.EARLY);
MacroEnvBuilder.registerProvider((env) => {
env.extra.order = [...(env.extra.order || []), 'LATE'];
}, env_provider_order.LATE);
MacroEnvBuilder.registerProvider((env) => {
env.extra.order = [...(env.extra.order || []), 'NORMAL'];
}, env_provider_order.NORMAL);
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return env.extra.order;
});
// We only guarantee relative ordering between the buckets we added,
// not that there are no other entries from other providers.
const earlyIndex = order.indexOf('EARLY');
const normalIndex = order.indexOf('NORMAL');
const lateIndex = order.indexOf('LATE');
expect(earlyIndex).toBeGreaterThanOrEqual(0);
expect(normalIndex).toBeGreaterThan(earlyIndex);
expect(lateIndex).toBeGreaterThan(normalIndex);
});
test('ignores provider errors without breaking env construction', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder, env_provider_order } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
MacroEnvBuilder.registerProvider(() => {
throw new Error('intentional test error');
}, env_provider_order.NORMAL);
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
name1Override: 'User',
dynamicMacros: { marker: 'value' },
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return {
namesUser: env.names?.user,
hasDynamicMacro: env.dynamicMacros?.marker === 'value',
};
});
expect(result.hasDynamicMacro).toBe(true);
expect(result.namesUser).toBe('User');
});
});