🎉 初始化项目

This commit is contained in:
2026-02-10 17:48:27 +08:00
parent f3da9c506a
commit db934ebed7
1575 changed files with 348967 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
module.exports = {
root: true,
plugins: [
'jest',
'playwright',
],
extends: [
'eslint:recommended',
'plugin:jest/recommended',
'plugin:playwright/recommended',
],
env: {
es6: true,
node: true,
'jest/globals': true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
overrides: [
],
ignorePatterns: [
'*.min.js',
'node_modules/**/*',
],
globals: {
},
rules: {
'no-unused-vars': ['error', { args: 'none' }],
'no-control-regex': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'require-yield': 'off',
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'indent': ['error', 4, { SwitchCase: 1, FunctionDeclaration: { parameters: 'first' } }],
'comma-dangle': ['error', 'always-multiline'],
'eol-last': ['error', 'always'],
'no-trailing-spaces': 'error',
'object-curly-spacing': ['error', 'always'],
'space-infix-ops': 'error',
'no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }],
'no-cond-assign': 'error',
// These rules should eventually be enabled.
'no-async-promise-executor': 'off',
'no-inner-declarations': 'off',
},
settings: {
jest: {
version: 29,
},
},
};

View File

@@ -0,0 +1,748 @@
import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
test.describe('MacroEngine', () => {
test.beforeEach(testSetup.awaitST);
test.describe('Basic evaluation', () => {
test('should return input unchanged when there are no macros', async ({ page }) => {
const input = 'Hello world, no macros here.';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(input);
});
test('should evaluate a simple macro without arguments', async ({ page }) => {
const input = 'Start {{newline}} end.';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Start \n end.');
});
test('should evaluate multiple macros in order', async ({ page }) => {
const input = 'A {{setvar::test::4}}{{getvar::test}} B {{setvar::test::2}}{{getvar::test}} C';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('A 4 B 2 C');
});
});
test.describe('Unnamed arguments', () => {
test('should handle normal double-colon separated unnamed argument', async ({ page }) => {
const input = 'Reversed: {{reverse::abc}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: cba!');
});
test('should handle (legacy) colon separated unnamed argument', async ({ page }) => {
const input = 'Reversed: {{reverse:abc}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: cba!');
});
test('should handle (legacy) colon separated argument as only one, even with more separators (double colon)', async ({ page }) => {
const input = 'Reversed: {{reverse:abc::def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: fed::cba!');
});
test('should handle (legacy) colon separated argument as only one, even with more separators (single colon)', async ({ page }) => {
const input = 'Reversed: {{reverse:abc:def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Reversed: fed:cba!');
});
test('should handle (legacy) whitespace separated unnamed argument', async ({ page }) => {
const input = 'Values: {{roll 1d1}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Values: 1!');
});
test('should handle (legacy) whitespace separated unnamed argument as only one, even with more separators (space)', async ({ page }) => {
const input = 'Values: {{reverse abc def}}!';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Values: fed cba!');
});
test('should support multi-line arguments for macros', async ({ page }) => {
const input = 'Result: {{reverse::first line\nsecond line}}'; // "\n" becomes a real newline in the macro argument
const output = await evaluateWithEngine(page, input);
const original = 'first line\nsecond line';
const expectedReversed = Array.from(original).reverse().join('');
expect(output).toBe(`Result: ${expectedReversed}`);
});
});
test.describe('Nested macros', () => {
test('should resolve nested macros inside arguments inside-out', async ({ page }) => {
const input = 'Result: {{setvar::test::0}}{{reverse::{{addvar::test::100}}{{getvar::test}}}}{{setvar::test::0}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: 001');
});
// {{wrap::{{upper::x}}::[::]}} -> '[X]'
test('should resolve nested macros across multiple arguments', async ({ page }) => {
const input = 'Result: {{setvar::addvname::test}}{{addvar::{{getvar::addvname}}::{{setvar::test::5}}{{getvar::test}}}}{{getvar::test}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: 10');
});
});
test.describe('Unknown macros', () => {
test('should keep unknown macro syntax but resolve nested macros inside it', async ({ page }) => {
const input = 'Test: {{unknown::{{newline}}}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Test: {{unknown::\n}}');
});
test('should keep surrounding text inside unknown macros intact', async ({ page }) => {
const input = 'Test: {{unknown::my {{newline}} example}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Test: {{unknown::my \n example}}');
});
});
test.describe('Comment macro', () => {
test('should remove single-line comments with simple body', async ({ page }) => {
const input = 'Hello{{// comment}}World';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('HelloWorld');
});
test('should accept non-word characters immediately after //', async ({ page }) => {
const input = 'A{{//!@#$%^&*()_+}}B';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('AB');
});
test('should ignore additional // sequences inside the comment body', async ({ page }) => {
const input = 'X{{//comment with // extra // slashes}}Y';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('XY');
});
test('should support multi-line comment bodies', async ({ page }) => {
const input = 'Start{{// line one\nline two\nline three}}End';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('StartEnd');
});
});
test.describe('Legacy compatibility', () => {
test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
const input = 'foo\n\n{{trim}}\n\nbar';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('foobar');
});
test('should handle multiple trim macros in a single string', async ({ page }) => {
const input = 'A\n\n{{trim}}\n\nB\n\n{{trim}}\n\nC';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('ABC');
});
test('should support legacy time macro with positive offset via pre-processing', async ({ page }) => {
const input = 'Time: {{time_UTC+2}}';
const output = await evaluateWithEngine(page, input);
// After pre-processing, this should behave like {{time::UTC+2}} and be resolved by the time macro.
// We only assert that the placeholder was consumed and some non-empty value was produced.
expect(output).not.toBe(input);
expect(output.startsWith('Time: ')).toBeTruthy();
expect(output.length).toBeGreaterThan('Time: '.length);
});
test('should support legacy time macro with negative offset via pre-processing', async ({ page }) => {
const input = 'Time: {{time_UTC-10}}';
const output = await evaluateWithEngine(page, input);
expect(output).not.toBe(input);
expect(output.startsWith('Time: ')).toBeTruthy();
expect(output.length).toBeGreaterThan('Time: '.length);
});
test('should support legacy <USER> marker via pre-processing', async ({ page }) => {
const input = 'Hello <USER>!';
const output = await evaluateWithEngine(page, input);
// In the default test env, name1Override is "User".
expect(output).toBe('Hello User!');
});
test('should support legacy <BOT> and <CHAR> markers via pre-processing', async ({ page }) => {
const input = 'Bot: <BOT>, Char: <CHAR>.';
const output = await evaluateWithEngine(page, input);
// In the default test env, name2Override is "Character".
expect(output).toBe('Bot: Character, Char: Character.');
});
test('should support legacy <GROUP> and <CHARIFNOTGROUP> markers via pre-processing (non-group fallback)', async ({ page }) => {
const input = 'Group: <GROUP>, CharIfNotGroup: <CHARIFNOTGROUP>.';
const output = await evaluateWithEngine(page, input);
// Without an active group, both markers fall back to the current character name.
expect(output).toBe('Group: Character, CharIfNotGroup: Character.');
});
});
test.describe('Bracket handling around macros', () => {
test('should allow single opening brace inside macro arguments', async ({ page }) => {
const input = 'Test§ {{reverse::my { test}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// "my { test" reversed becomes "tset { ym"
expect(output).toBe('Test§ tset { ym');
const EXPECT_WARNINGS = false;
const EXPECT_ERRORS = false;
expect(hasMacroWarnings).toBe(EXPECT_WARNINGS);
expect(hasMacroErrors).toBe(EXPECT_ERRORS);
});
test('should allow single closing brace inside macro arguments', async ({ page }) => {
const input = 'Test§ {{reverse::my } test}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// "my } test" reversed becomes "tset } ym"
expect(output).toBe('Test§ tset } ym');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should treat unterminated macro with identifier at end of input as plain text', async ({ page }) => {
const input = 'Test {{ hehe';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should treat invalid macro start as plain text when followed by non-identifier characters', async ({ page }) => {
const input = 'Test {{§§ hehe';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
expect(hasMacroErrors).toBe(false);
});
test('should treat unterminated macro in the middle of the string as plain text', async ({ page }) => {
const input = 'Before {{ hehe After';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should treat dangling macro start as text and still evaluate subsequent macro', async ({ page }) => {
const input = 'Test {{ hehe {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Default test env uses name1Override = "User" and name2Override = "Character".
expect(output).toBe('Test {{ hehe User');
expect(hasMacroWarnings).toBe(true);
expect(hasMacroErrors).toBe(false);
});
test('should ignore invalid macro start but still evaluate following valid macro', async ({ page }) => {
const input = 'Test {{&& hehe {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Default test env uses name1Override = "User" and name2Override = "Character".
expect(output).toBe('Test {{&& hehe User');
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
expect(hasMacroErrors).toBe(false);
});
test('should allow single opening brace immediately before a macro', async ({ page }) => {
const input = '{{{char}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// One literal '{' plus the resolved character name.
expect(output).toBe('{Character');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow single closing brace immediately after a macro', async ({ page }) => {
const input = '{{char}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow single braces around a macro', async ({ page }) => {
const input = '{{{char}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{Character}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double opening braces immediately before a macro', async ({ page }) => {
const input = '{{{{char}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{{Character');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double closing braces immediately after a macro', async ({ page }) => {
const input = '{{char}}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character}}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should allow double braces around a macro', async ({ page }) => {
const input = '{{{{char}}}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('{{Character}}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should resolve nested macro inside argument with surrounding braces', async ({ page }) => {
const input = 'Result: {{reverse::pre-{ {{user}} }-post}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
// Argument "pre-{ User }-post" reversed becomes "tsop-} resU {-erp".
expect(output).toBe('Result: tsop-} resU {-erp');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle adjacent macros with no separator', async ({ page }) => {
const input = '{{char}}{{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('CharacterUser');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle macros separated only by surrounding braces', async ({ page }) => {
const input = '{{char}}{ {{user}} }';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Character{ User }');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle Windows newlines with braces near macros', async ({ page }) => {
const input = 'Line1 {{char}}\r\n{Line2}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Line1 Character\r\n{Line2}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should treat stray closing braces outside macros as plain text', async ({ page }) => {
const input = 'Foo }} bar';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe(input);
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should keep stray closing braces and still evaluate following macro', async ({ page }) => {
const input = 'Foo }} {{user}}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Foo }} User');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
test('should handle stray closing braces before macros as plain text', async ({ page }) => {
const input = 'Foo {{user}} }}';
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
expect(output).toBe('Foo User }}');
expect(hasMacroWarnings).toBe(false);
expect(hasMacroErrors).toBe(false);
});
});
test.describe('Arity errors', () => {
test('should not resolve newline when called with arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Start {{newline::extra}} end.';
const output = await evaluateWithEngine(page, input);
// Macro text should remain unchanged
expect(output).toBe(input);
// Should have logged an arity warning for newline
expect(warnings.some(w => w.includes('Macro "newline"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve reverse when called without arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Result: {{reverse}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe(input);
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve reverse when called with too many arguments', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Result: {{reverse::a::b}}';
const output = await evaluateWithEngine(page, input);
// Macro text should remain unchanged when extra unnamed args are provided
expect(output).toBe(input);
// Should have logged an arity warning for reverse
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
});
test('should not resolve list-bounded macro when called outside list bounds', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
// Register a temporary macro with explicit list bounds: exactly 1 required + 1-2 list args
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-list-bounds');
MacroRegistry.registerMacro('test-list-bounds', {
unnamedArgs: 1,
list: { min: 1, max: 2 },
description: 'Test macro for list bounds.',
handler: ({ unnamedArgs, list }) => {
const all = [...unnamedArgs, ...(list ?? [])];
return all.join('|');
},
});
});
// First macro: too few list args (only required arg)
// Second macro: too many list args (required arg + 3 list entries)
const input = 'A {{test-list-bounds::base}} B {{test-list-bounds::base::x::y::z}}';
const output = await evaluateWithEngine(page, input);
// Both macros should remain unchanged in the output
expect(output).toBe(input);
const testWarnings = warnings.filter(w => w.includes('Macro "test-list-bounds"') && w.includes('unnamed arguments'));
// We expect one warning for each invalid invocation (too few and too many list args)
expect(testWarnings.length).toBe(2);
});
test('should resolve nested macros in arguments, even though the outer macro has wrong number of arguments', async ({ page }) => {
// Macro {{user ....}} will fail, because it has no args, but {{char}} should still resolve
const input = 'Result: {{user Something {{char}}}}';
const output = await evaluateWithEngine(page, input);
expect(output).toBe('Result: {{user Something Character}}');
});
});
test.describe('Type validation', () => {
test('should not resolve strict typed macro when argument type is invalid', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-int-strict');
MacroRegistry.registerMacro('test-int-strict', {
unnamedArgs: [
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
],
strictArgs: true,
description: 'Strict integer macro for testing type validation.',
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
});
});
const input = 'Value: {{test-int-strict::abc}}';
const output = await evaluateWithEngine(page, input);
// Strict typed macro should leave the text unchanged when the argument is invalid
expect(output).toBe(input);
// A runtime type validation warning should be logged
expect(warnings.some(w => w.includes('Macro "test-int-strict"') && w.includes('expected type integer'))).toBeTruthy();
});
test('should resolve non-strict typed macro when argument type is invalid but still log warning', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-int-nonstrict');
MacroRegistry.registerMacro('test-int-nonstrict', {
unnamedArgs: [
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
],
strictArgs: false,
description: 'Non-strict integer macro for testing type validation.',
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
});
});
const input = 'Value: {{test-int-nonstrict::abc}}';
const output = await evaluateWithEngine(page, input);
// Non-strict typed macro should still execute, even with invalid type
expect(output).toBe('Value: #abc#');
// A runtime type validation warning should still be logged
expect(warnings.some(w => w.includes('Macro "test-int-nonstrict"') && w.includes('expected type integer'))).toBeTruthy();
});
});
test.describe('Environment', () => {
test('should expose original content as env.content to macro handlers', async ({ page }) => {
const input = '{{env-content}}';
const originalContent = 'This is the full original input string.';
const output = await page.evaluate(async ({ input, originalContent }) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('env-content');
MacroRegistry.registerMacro('env-content', {
description: 'Test macro that returns env.content.',
handler: ({ env }) => env.content,
});
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const rawEnv = {
content: originalContent,
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, { input, originalContent });
expect(output).toBe(originalContent);
});
});
test.describe('Deterministic pick macro', () => {
test('should return stable results for the same chat and content', async ({ page }) => {
// Simulate a consistent chat id hash
let originalHash;
await page.evaluate(async ([originalHash]) => {
/** @type {import('../../public/script.js')} */
const { chat_metadata } = await import('./script.js');
originalHash = chat_metadata['chat_id_hash'];
chat_metadata['chat_id_hash'] = 123456;
}, [originalHash]);
const input = 'Choices: {{pick::red::green::blue}}, {{pick::red::green::blue}}.';
const output1 = await evaluateWithEngine(page, input);
const output2 = await evaluateWithEngine(page, input);
// Deterministic: same chat and same content should yield identical output.
expect(output1).toBe(output2);
// Sanity check: both picks should resolve to one of the provided options.
const match = output1.match(/Choices: ([^,]+), ([^.]+)\./);
expect(match).not.toBeNull();
if (!match) return;
const first = match[1].trim();
const second = match[2].trim();
const options = ['red', 'green', 'blue'];
expect(options.includes(first)).toBeTruthy();
expect(options.includes(second)).toBeTruthy();
// Restore original hash
await page.evaluate(async ([originalHash]) => {
/** @type {import('../../public/script.js')} */
const { chat_metadata } = await import('./script.js');
chat_metadata['chat_id_hash'] = originalHash;
}, [originalHash]);
});
});
test.describe('Dynamic macros', () => {
test('should not resolve dynamic macro when called with arguments due to strict arity', async ({ page }) => {
/** @type {string[]} */
const warnings = [];
page.on('console', msg => {
if (msg.type() === 'warning') {
warnings.push(msg.text());
}
});
const input = 'Dyn: {{dyn::extra}}';
const output = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.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 rawEnv = {
content: input,
dynamicMacros: {
dyn: () => 'OK',
},
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
return MacroEngine.evaluate(input, env);
}, input);
// Dynamic macro with arguments should not resolve because the
// temporary definition is strictArgs: true and minArgs/maxArgs: 0.
expect(output).toBe(input);
// A runtime arity warning for the dynamic macro should be logged
expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
});
});
});
/**
* Evaluates the given input string using the MacroEngine inside the browser
* context, ensuring that the core macros are registered.
*
* @param {import('@playwright/test').Page} page
* @param {string} input
* @returns {Promise<string>}
*/
async function evaluateWithEngine(page, input) {
const result = await page.evaluate(async (input) => {
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.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 rawEnv = {
content: input,
name1Override: 'User',
name2Override: 'Character',
};
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
const output = await MacroEngine.evaluate(input, env);
return output;
}, input);
return result;
}
/**
* Evaluates the given input string while capturing whether any macro-related
* warnings or errors were logged to the browser console.
*
* This is useful for tests that want to assert both the resolved output and
* whether the lexer/parser/engine reported issues (e.g. unterminated macros).
*
* @param {import('@playwright/test').Page} page
* @param {string} input
* @returns {Promise<{ output: string, hasMacroWarnings: boolean, hasMacroErrors: boolean }>}
*/
async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
/** @type {boolean} */
let hasMacroWarnings = false;
/** @type {boolean} */
let hasMacroErrors = false;
/** @param {import('playwright').ConsoleMessage} msg */
const handler = (msg) => {
const text = msg.text();
if (text.includes('[Macro] Warning:')) {
hasMacroWarnings = true;
}
if (text.includes('[Macro] Error:')) {
hasMacroErrors = true;
}
};
page.on('console', handler);
try {
const output = await evaluateWithEngine(page, input);
return { output, hasMacroWarnings, hasMacroErrors };
} finally {
page.off('console', handler);
}
}

View File

@@ -0,0 +1,312 @@
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',
]));
});
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('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');
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
const ctx = {
content: '',
dynamicMacros: {
simple: 'value',
number: 42,
nested: { foo: 'bar' },
},
};
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
return env.dynamicMacros;
});
expect(dynamicMacros.simple).toBe('value');
expect(dynamicMacros.number).toBe(42);
expect(dynamicMacros.nested).toEqual({ foo: 'bar' });
});
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');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,678 @@
import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
/** @typedef {{[tokenName: string]: (string|string[]|TestableCstNode|TestableCstNode[])}} TestableCstNode */
/** @typedef {{name: string, message: string}} TestableRecognitionException */
const DEFAULT_FLATTEN_KEYS = [
'arguments.Args.DoubleColon',
];
const DEFAULT_IGNORE_KEYS = [
];
test.describe('MacroParser', () => {
// Currently this test suits runs without ST context. Enable, if ever needed
test.beforeEach(testSetup.goST);
test.describe('General Macro', () => {
// {{user}}
test('should parse a simple macro', async ({ page }) => {
const input = '{{user}}';
const macroCst = await runParser(page, input);
const expectedCst = {
'Macro.Start': '{{',
'Macro.identifier': 'user',
'Macro.End': '}}',
};
expect(macroCst).toEqual(expectedCst);
});
// {{ user }}
test('should generally handle whitespaces', async ({ page }) => {
const input = '{{ user }}';
const macroCst = await runParser(page, input);
const expectedCst = {
'Macro.Start': '{{',
'Macro.identifier': 'user',
'Macro.End': '}}',
};
expect(macroCst).toEqual(expectedCst);
});
test.describe('Error Cases (General Macro)', () => {
// {{}}
test('[Error] should throw an error for empty macro', async ({ page }) => {
const input = '{{}}';
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;
expect(macroCst).toBeUndefined();
expect(errors).toMatchObject(expectedErrors);
expect(errors[0].message).toMatch(expectedMessage);
});
// {{§!#&blah}}
test('[Error] should throw an error for invalid identifier', async ({ page }) => {
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;
expect(macroCst).toBeUndefined();
expect(errors).toMatchObject(expectedErrors);
expect(errors[0].message).toMatch(expectedMessage);
});
// {{user
test('[Error] should throw an error for incomplete macro', async ({ page }) => {
const input = '{{user';
const { macroCst, errors } = await runParserAndGetErrors(page, input);
const expectedErrors = [
{ name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.End <-- but found --> \'\' <--' },
];
expect(macroCst).toBeUndefined();
expect(errors).toEqual(expectedErrors);
});
// something{{user}}
test('[Error] for testing purposes, macros need to start at the beginning of the string', async ({ page }) => {
const input = 'something{{user}}';
const { macroCst, errors } = await runParserAndGetErrors(page, input);
const expectedErrors = [
{ name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.Start <-- but found --> \'something\' <--' },
];
expect(macroCst).toBeUndefined();
expect(errors).toEqual(expectedErrors);
});
});
});
test.describe('Arguments Handling', () => {
// {{getvar::myvar}}
test('should parse macros with double-colon argument', async ({ page }) => {
const input = '{{getvar::myvar}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'getvar',
'arguments': {
'separator': '::',
'argument': 'myvar',
},
'Macro.End': '}}',
});
});
// {{roll:3d20}}
test('should parse macros with single colon argument', async ({ page }) => {
const input = '{{roll:3d20}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'roll',
'arguments': {
'separator': ':',
'argument': '3d20',
},
'Macro.End': '}}',
});
});
// {{setvar::myvar::value}}
test('should parse macros with multiple double-colon arguments', async ({ page }) => {
const input = '{{setvar::myvar::value}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
ignoreKeys: ['arguments.Args.DoubleColon'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'setvar',
'arguments': {
'separator': '::',
'argument': ['myvar', 'value'],
},
'Macro.End': '}}',
});
});
// {{something:: spaced }}
test('should strip spaces around arguments', async ({ page }) => {
const input = '{{something:: spaced }}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
ignoreKeys: ['arguments.separator', 'arguments.Args.DoubleColon'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'something',
'arguments': { 'argument': 'spaced' },
'Macro.End': '}}',
});
});
// {{something::with:single:colons}}
test('should treat single colons as part of the argument with double-colon separator', async ({ page }) => {
const input = '{{something::with:single:colons}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
ignoreKeys: ['arguments.Args.DoubleColon'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'something',
'arguments': {
'separator': '::',
'argument': 'with:single:colons',
},
'Macro.End': '}}',
});
});
// {{legacy:something:else}}
test('should treat single colons as part of the argument even with colon separator', async ({ page }) => {
const input = '{{legacy:something:else}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
ignoreKeys: ['arguments.separator', 'arguments.Args.Colon'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'legacy',
'arguments': { 'argument': 'something:else' },
'Macro.End': '}}',
});
});
// {{something::}}
test('should parse double-colon with an empty argument value', async ({ page }) => {
const input = '{{something::}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'something',
'arguments': {
'separator': '::',
'argument': '',
},
'Macro.End': '}}',
});
});
});
test.describe('Legacy Macros', () => {
// {{roll 1d5}}
test('should parse legacy roll macro with whitespace separator', async ({ page }) => {
const input = '{{roll 1d5}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'roll',
'arguments': { 'argument': '1d5' },
'Macro.End': '}}',
});
});
// {{roll:2d20}}
test('should parse legacy roll macro with explicit colon separator', async ({ page }) => {
const input = '{{roll:2d20}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'roll',
'arguments': {
'separator': ':',
'argument': '2d20',
},
'Macro.End': '}}',
});
});
// {{roll 20}}
test('should parse legacy roll macro with numeric argument', async ({ page }) => {
const input = '{{roll 20}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'roll',
'arguments': { 'argument': '20' },
'Macro.End': '}}',
});
});
// {{reverse:something}}
test('should parse reverse legacy macro with colon argument', async ({ page }) => {
const input = '{{reverse:something}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'reverse',
'arguments': {
'separator': ':',
'argument': 'something',
},
'Macro.End': '}}',
});
});
// {{reverse:this contains::double::colons}}
test('should parse legacy single colon argument that allows double colons inside the argument', async ({ page }) => {
const input = '{{reverse:this contains::double::colons}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'reverse',
'arguments': {
'separator': ':',
'argument': 'this contains::double::colons',
},
'Macro.End': '}}',
});
});
// {{//comment-style macro}}
// TODO: Comment like // is not a valid identifier, needs to be an exception (until we maybe add flags)
test('should parse legacy comment macro', async ({ page }) => {
const input = '{{//comment-style macro}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'arguments': { 'argument': 'comment-style macro' },
'Macro.End': '}}',
});
});
// {{datetimeformat HH:mm}}
test('should parse legacy datetime format macro', async ({ page }) => {
const input = '{{datetimeformat HH:mm}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'datetimeformat',
'arguments': { 'argument': 'HH:mm' },
'Macro.End': '}}',
});
});
// Note: Legacy time macros like {{time_UTC+2}} are now handled by the MacroEngine
// pre-processing pipeline instead of the parser. See MacroEngine.e2e tests for coverage.
// {{banned "abannedword"}}
test('should parse legacy banned macro with quoted argument', async ({ page }) => {
const input = '{{banned "abannedword"}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'banned',
'arguments': { 'argument': '"abannedword"' },
'Macro.End': '}}',
});
});
// {{banned ""}}
test('should parse legacy macro with empty quoted argument', async ({ page }) => {
const input = '{{banned ""}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'banned',
'arguments': { 'argument': '""' },
'Macro.End': '}}',
});
});
// {{setvar::myvar::}}
test('should allow legacy setvar with empty value argument', async ({ page }) => {
const input = '{{setvar::myvar::}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'setvar',
'arguments': {
'separator': '::',
'argument': ['myvar', ''],
},
'Macro.End': '}}',
});
});
});
test.describe('Comment Macros', () => {
// {{//comment}}
test('should parse comment macro without whitespace', async ({ page }) => {
const input = '{{//comment}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'Macro.End': '}}',
'arguments': {
'argument': 'comment',
},
});
});
// {{// comment}}
test('should parse comment macro with whitespace', async ({ page }) => {
const input = '{{// comment}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'Macro.End': '}}',
'arguments': {
'argument': 'comment',
},
});
});
// {{//!@#$%^&*()_+}}
test('should parse comment macro with special characters', async ({ page }) => {
const input = '{{//!@#$%^&*()_+}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'Macro.End': '}}',
'arguments': {
'argument': '!@#$%^&*()_+',
},
});
});
// {{//!@flags}}
test('should parse comment macro starting with flags', async ({ page }) => {
const input = '{{//!@flags}}';
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'Macro.End': '}}',
'arguments': {
'argument': '!@flags',
},
});
});
// {{// This is a multiline comment.
// This is the second line
// }}
test('should parse multiline comments', async ({ page }) => {
const input = `{{// This is a multiline comment.
This is the second line
}}`;
const macroCst = await runParser(page, input, {
flattenKeys: ['arguments.argument'],
});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': '//',
'Macro.End': '}}',
'arguments': {
'argument': 'This is a multiline comment.\nThis is the second line',
},
});
});
});
test.describe('Nested Macros', () => {
// {{outer::word {{inner}}}}
test('should parse nested macros inside arguments', async ({ page }) => {
const input = '{{outer::word {{inner}}}}';
const macroCst = await runParser(page, input, {});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'outer',
'arguments': {
'argument': {
'Identifier': 'word',
'macro': {
'Macro.Start': '{{',
'Macro.identifier': 'inner',
'Macro.End': '}}',
},
},
'separator': '::',
},
'Macro.End': '}}',
});
});
// {{outer::word {{inner1}}{{inner2}}}}
test('should parse two nested macros next to each other inside an argument', async ({ page }) => {
const input = '{{outer::word {{inner1}}{{inner2}}}}';
const macroCst = await runParser(page, input, {});
expect(macroCst).toEqual({
'Macro.Start': '{{',
'Macro.identifier': 'outer',
'arguments': {
'argument': {
'Identifier': 'word',
'macro': [
{
'Macro.Start': '{{',
'Macro.identifier': 'inner1',
'Macro.End': '}}',
},
{
'Macro.Start': '{{',
'Macro.identifier': 'inner2',
'Macro.End': '}}',
},
],
},
'separator': '::',
},
'Macro.End': '}}',
});
});
test.describe('Error Cases (Nested Macros)', () => {
// {{{{macroindentifier}}::value}}
test('[Error] should throw when there is a nested macro instead of an identifier', async ({ page }) => {
const input = '{{{{macroindentifier}}::value}}';
const { macroCst, errors } = await runParserAndGetErrors(page, input);
expect(macroCst).toBeUndefined();
expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
});
// {{inside{{macro}}me}}
test('[Error] should throw when there is a macro inside an identifier', async ({ page }) => {
const input = '{{inside{{macro}}me}}';
const { macroCst, errors } = await runParserAndGetErrors(page, input);
expect(macroCst).toBeUndefined();
expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
});
});
});
});
/**
* Runs the input through the MacroParser and returns the result.
*
* @param {import('@playwright/test').Page} page - The Playwright page object.
* @param {string} input - The input string to be parsed.
* @param {Object} [options={}] Optional arguments
* @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
* @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
* @returns {Promise<TestableCstNode>} A promise that resolves to the result of the MacroParser.
*/
async function runParser(page, input, options = {}) {
const { cst, errors } = await runParserAndGetErrors(page, input, options);
// Make sure that parser errors get correctly marked as errors during testing, even if the resulting structure might work.
// If we don't test for errors, the test should fail.
if (errors.length > 0) {
throw new Error('Parser errors found\n' + errors.map(x => x.message).join('\n'));
}
return cst;
}
/**
* Runs the input through the MacroParser and returns the syntax tree result and any parser errors.
*
* Use `runParser` if you don't want to explicitly test against parser errors.
*
* @param {import('@playwright/test').Page} page - The Playwright page object.
* @param {string} input - The input string to be parsed.
* @param {Object} [options={}] Optional arguments
* @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
* @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
* @returns {Promise<{cst: TestableCstNode, errors: TestableRecognitionException[]}>} A promise that resolves to the result of the MacroParser and error list.
*/
async function runParserAndGetErrors(page, input, options = {}) {
const params = { input, options };
const { result } = await page.evaluate(async ({ input, options }) => {
/** @type {import('../../public/scripts/macros/engine/MacroParser.js')} */
const { MacroParser } = await import('./scripts/macros/engine/MacroParser.js');
const result = MacroParser.test(input);
return { result };
}, params);
return { cst: simplifyCstNode(result.cst, input, options), errors: simplifyErrors(result.errors) };
}
/**
* Simplify the parser syntax tree result into an easily testable format.
*
* @param {CstNode} result The result from the parser
* @param {Object} [options={}] Optional arguments
* @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
* @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
* @returns {TestableCstNode} The testable syntax tree
*/
function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignoreDefaultFlattenKeys = false, ignoreDefaultIgnoreKeys = false } = {}) {
if (!ignoreDefaultFlattenKeys) flattenKeys = [...flattenKeys, ...DEFAULT_FLATTEN_KEYS];
if (!ignoreDefaultIgnoreKeys) ignoreKeys = [...ignoreKeys, ...DEFAULT_IGNORE_KEYS];
/** @returns {TestableCstNode} @param {CstNode} node @param {string[]} path */
function simplifyNode(node, path = []) {
if (!node) return node;
if (Array.isArray(node)) {
// Single-element arrays are converted to a single string
if (node.length === 1) {
return node[0].image || simplifyNode(node[0], path.concat('[]'));
}
// For multiple elements, return an array of simplified nodes
return node.map(child => simplifyNode(child, path.concat('[]')));
}
if (node.children) {
const simplifiedChildren = {};
for (const key in node.children) {
function simplifyChildNode(childNode, path) {
if (Array.isArray(childNode)) {
// Single-element arrays are converted to a single string
if (childNode.length === 1) {
return simplifyChildNode(childNode[0], path.concat('[]'));
}
return childNode.map(child => simplifyChildNode(child, path.concat('[]')));
}
const flattenKey = path.filter(x => x !== '[]').join('.');
if (ignoreKeys.includes(flattenKey)) {
return null;
} else if (flattenKeys.includes(flattenKey)) {
if (!childNode.location) return null;
const startOffset = childNode.location.startOffset;
const endOffset = childNode.location.endOffset;
return input.slice(startOffset, endOffset + 1);
} else {
return simplifyNode(childNode, path);
}
}
const simplifiedValue = simplifyChildNode(node.children[key], path.concat(key));
if (simplifiedValue !== null) simplifiedChildren[key] = simplifiedValue;
}
if (Object.values(simplifiedChildren).length === 0) return null;
return simplifiedChildren;
}
return node.image;
}
return simplifyNode(cst);
}
/**
* Simplifies a recognition exceptions into an easily testable format.
*
* @param {IRecognitionException[]} errors - The error list containing exceptions to be simplified.
* @return {TestableRecognitionException[]} - The simplified error list
*/
function simplifyErrors(errors) {
return errors.map(exception => ({
name: exception.name,
message: exception.message,
}));
}

View File

@@ -0,0 +1,153 @@
import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
test.describe('MacroRegistry', () => {
// Currently this test suits runs without ST context. Enable, if ever needed
test.beforeEach(testSetup.awaitST);
test.describe('valid', () => {
test('should register a macro with valid options', async ({ page }) => {
const result = await page.evaluate(async () => {
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
MacroRegistry.unregisterMacro('test-valid');
MacroRegistry.registerMacro('test-valid', {
unnamedArgs: 2,
list: { min: 1, max: 3 },
strictArgs: false,
description: 'Test macro for validation.',
handler: ({ args }) => args.join(','),
});
const def = MacroRegistry.getMacro('test-valid');
return {
name: def?.name,
minArgs: def?.minArgs,
maxArgs: def?.maxArgs,
list: def?.list,
strictArgs: def?.strictArgs,
description: def?.description,
};
});
expect(result).toEqual({
name: 'test-valid',
minArgs: 2,
maxArgs: 2,
list: { min: 1, max: 3 },
strictArgs: false,
description: 'Test macro for validation.',
});
});
});
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/);
});
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/);
});
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/);
});
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
unnamedArgs: -1,
handler: () => '',
});
})).rejects.toThrow(/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
strictArgs: 'yes',
handler: () => '',
});
})).rejects.toThrow(/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
list: 'invalid',
handler: () => '',
});
})).rejects.toThrow(/options\.list must be a boolean or an object/);
});
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', {
list: { min: -1 },
handler: () => '',
});
})).rejects.toThrow(/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', {
list: { min: 2, max: 1 },
handler: () => '',
});
})).rejects.toThrow(/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
description: 123,
handler: () => '',
});
})).rejects.toThrow(/options\.description must be a string/);
});
});
});

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { testSetup } from './frontent-test-utils.js';
// Tests for the deprecated MacrosParser shim to ensure it continues to work
// both with the legacy regex macro system (feature flag disabled) and with
// the new macro engine (feature flag enabled).
test.describe('MacrosParser (legacy shim)', () => {
test.beforeEach(testSetup.awaitST);
test('should resolve macros via legacy evaluateMacros when experimental engine is disabled', async ({ page }) => {
const output = await page.evaluate(async () => {
const { MacrosParser, evaluateMacros } = await import('./scripts/macros.js');
const { power_user } = await import('./scripts/power-user.js');
power_user.experimental_macro_engine = false;
MacrosParser.registerMacro('legacyParserTest', 'LEGACY_OK', 'Legacy parser test');
const env = {};
const result = evaluateMacros('Value: {{legacyParserTest}}.', env, (x) => x);
MacrosParser.unregisterMacro('legacyParserTest');
return result;
});
expect(output).toBe('Value: LEGACY_OK.');
});
test('should resolve macros via new engine when experimental engine is enabled', async ({ page }) => {
const output = await page.evaluate(async () => {
const { MacrosParser } = await import('./scripts/macros.js');
const { substituteParams } = await import('./script.js');
const { power_user } = await import('./scripts/power-user.js');
power_user.experimental_macro_engine = true;
MacrosParser.registerMacro('engineParserTest', 'ENGINE_OK', 'Engine parser test');
const result = substituteParams('Value: {{engineParserTest}}.', {});
MacrosParser.unregisterMacro('engineParserTest');
return result;
});
expect(output).toBe('Value: ENGINE_OK.');
});
});

View File

@@ -0,0 +1,22 @@
export const testSetup = {
/**
* Navigates to the home page without waiting for SillyTavern to load.
* @param {Object} params
* @param {import('@playwright/test').Page} params.page
*/
goST: async ({ page }) => {
await page.goto('/');
},
/**
* Waits for SillyTavern to fully load by navigating to the home page and waiting for the preloader to disappear.
* @param {Object} params
* @param {import('@playwright/test').Page} params.page
*/
awaitST: async ({ page }) => {
await page.goto('/');
await page.click('#userList .userSelect:last-child');
await page.waitForURL('http://127.0.0.1:8000');
await page.waitForFunction('document.getElementById("preloader") === null', { timeout: 0 });
},
};

View File

@@ -0,0 +1,5 @@
{
"verbose": true,
"transform": {},
"testEnvironment": "node"
}

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
},
}

View File

@@ -0,0 +1,34 @@
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import { MockServer } from './util/mock-server.js';
describe('MockServer tests', () => {
/** @type {MockServer} */
const mockServer = new MockServer({ port: 3000, host: '127.0.0.1' });
beforeAll(async () => {
await mockServer.start();
});
afterAll(async () => {
await mockServer.stop();
});
test('should provide OpenAI-compatible endpoint', async () => {
const requestBody = {
model: 'gpt-4o',
max_tokens: 400,
messages: [
{ role: 'user', content: 'Hello, world!' },
],
};
const response = await fetch('http://127.0.0.1:3000/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const expectedResponse = { 'choices': [{ 'finish_reason': 'stop', 'index': 0, 'message': { 'role': 'assistant', 'reasoning_content': 'gpt-4o\n1\n400', 'content': 'Hello, world!' } }], 'created': 0, 'model': 'gpt-4o' };
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(expectedResponse);
});
});

5076
web-app/tests/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "sillytavern-tests",
"type": "module",
"license": "AGPL-3.0",
"scripts": {
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.json",
"test:e2e": "playwright test",
"lint": "eslint \"**/*.js\" ./*.js",
"lint:fix": "eslint \"**/*.js\" ./*.js --fix"
},
"dependencies": {
"@playwright/test": "^1.56.1",
"@types/jest": "^29.5.12",
"eslint": "^8.57.0",
"jest": "^29.7.0"
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testMatch: '*.e2e.js',
use: {
baseURL: 'http://127.0.0.1:8000',
video: 'only-on-failure',
screenshot: 'only-on-failure',
},
workers: 4,
fullyParallel: true,
});

View File

@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
test.describe('sample', () => {
test.beforeEach(async({ page }) => {
await page.goto('/');
await page.waitForFunction('document.getElementById("preloader") === null', { timeout: 0 });
});
test('should be titled "SillyTavern"', async ({ page }) => {
await expect(page).toHaveTitle('SillyTavern');
});
});

107
web-app/tests/util.test.js Normal file
View File

@@ -0,0 +1,107 @@
import { describe, test, expect } from '@jest/globals';
import { CHAT_COMPLETION_SOURCES } from '../src/constants';
import { flattenSchema } from '../src/util';
describe('flattenSchema', () => {
test('should return the schema if it is not an object', () => {
const schema = 'it is not an object';
expect(flattenSchema(schema, CHAT_COMPLETION_SOURCES.MAKERSUITE)).toBe(schema);
});
test('should handle schema with $defs and $ref', () => {
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$defs: {
a: { type: 'string' },
b: {
type: 'object',
properties: {
c: { $ref: '#/$defs/a' },
},
},
},
properties: {
d: { $ref: '#/$defs/b' },
},
};
const expected = {
properties: {
d: {
type: 'object',
properties: {
c: { type: 'string' },
},
},
},
};
expect(flattenSchema(schema, CHAT_COMPLETION_SOURCES.MAKERSUITE)).toEqual(expected);
});
test('should filter unsupported properties for Google API schema', () => {
const schema = {
$defs: {
a: {
type: 'string',
default: 'test',
},
},
type: 'object',
properties: {
b: { $ref: '#/$defs/a' },
c: { type: 'number' },
},
additionalProperties: false,
exclusiveMinimum: 0,
propertyNames: {
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
},
};
const expected = {
type: 'object',
properties: {
b: {
type: 'string',
},
c: { type: 'number' },
},
};
expect(flattenSchema(schema, CHAT_COMPLETION_SOURCES.MAKERSUITE)).toEqual(expected);
});
test('should not filter properties for non-Google API schema', () => {
const schema = {
$defs: {
a: {
type: 'string',
default: 'test',
},
},
type: 'object',
properties: {
b: { $ref: '#/$defs/a' },
c: { type: 'number' },
},
additionalProperties: false,
exclusiveMinimum: 0,
propertyNames: {
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
},
};
const expected = {
type: 'object',
properties: {
b: {
type: 'string',
default: 'test',
},
c: { type: 'number' },
},
additionalProperties: false,
exclusiveMinimum: 0,
propertyNames: {
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
},
};
expect(flattenSchema(schema, 'some-other-api')).toEqual(expected);
});
});

View File

@@ -0,0 +1,101 @@
import http from 'node:http';
import { readAllChunks, tryParse } from '../../src/util.js';
export class MockServer {
/** @type {string} */
host;
/** @type {number} */
port;
/** @type {import('http').Server} */
server;
/**
* Creates an instance of MockServer.
* @param {object} [param] Options object.
* @param {string} [param.host] The hostname or IP address to bind the server to.
* @param {number} [param.port] The port number to listen on.
*/
constructor({ host, port } = {}) {
this.host = host ?? '127.0.0.1';
this.port = port ?? 3000;
}
/**
* Handles Chat Completions requests.
* @param {object} jsonBody The parsed JSON body from the request.
* @returns {object} Mock response object.
*/
handleChatCompletions(jsonBody) {
const messages = jsonBody?.messages;
const lastMessage = messages?.[messages.length - 1];
const mockResponse = {
choices: [
{
finish_reason: 'stop',
index: 0,
message: {
role: 'assistant',
reasoning_content: `${jsonBody?.model}\n${messages?.length}\n${jsonBody?.max_tokens}`,
content: String(lastMessage?.content ?? 'No prompt messages.'),
},
},
],
created: 0,
model: jsonBody?.model,
};
return mockResponse;
}
/**
* Starts the mock server.
* @returns {Promise<void>}
*/
async start() {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
try {
const body = await readAllChunks(req);
const jsonBody = tryParse(body.toString());
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
const mockResponse = this.handleChatCompletions(jsonBody);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(mockResponse));
} else {
res.writeHead(404);
res.end();
}
} catch (error) {
res.writeHead(500);
res.end();
}
});
this.server.on('error', (err) => {
reject(err);
});
this.server.listen(this.port, this.host, () => {
resolve();
});
});
}
/**
* Stops the mock server.
* @returns {Promise<void>}
*/
async stop() {
return new Promise((resolve, reject) => {
if (!this.server) {
return reject(new Error('Server is not running.'));
}
this.server.closeAllConnections();
this.server.close(( /** @type {NodeJS.ErrnoException|undefined} */ err) => {
if (err && (err?.code !== 'ERR_SERVER_NOT_RUNNING')) {
return reject(err);
}
resolve();
});
});
}
}