b5a1d227fd
* 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>
500 lines
20 KiB
JavaScript
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');
|
|
});
|
|
});
|