🎉 初始化项目
This commit is contained in:
54
web-app/tests/.eslintrc.cjs
Normal file
54
web-app/tests/.eslintrc.cjs
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
748
web-app/tests/frontend/MacroEngine.e2e.js
Normal file
748
web-app/tests/frontend/MacroEngine.e2e.js
Normal 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);
|
||||
}
|
||||
}
|
||||
312
web-app/tests/frontend/MacroEnvBuilder.e2e.js
Normal file
312
web-app/tests/frontend/MacroEnvBuilder.e2e.js
Normal 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');
|
||||
});
|
||||
});
|
||||
1136
web-app/tests/frontend/MacroLexer.e2e.js
Normal file
1136
web-app/tests/frontend/MacroLexer.e2e.js
Normal file
File diff suppressed because it is too large
Load Diff
678
web-app/tests/frontend/MacroParser.e2e.js
Normal file
678
web-app/tests/frontend/MacroParser.e2e.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
153
web-app/tests/frontend/MacroRegistry.e2e.js
Normal file
153
web-app/tests/frontend/MacroRegistry.e2e.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
web-app/tests/frontend/MacrosParser.e2e.js
Normal file
50
web-app/tests/frontend/MacrosParser.e2e.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
22
web-app/tests/frontend/frontent-test-utils.js
Normal file
22
web-app/tests/frontend/frontent-test-utils.js
Normal 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 });
|
||||
},
|
||||
};
|
||||
5
web-app/tests/jest.config.json
Normal file
5
web-app/tests/jest.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"transform": {},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
5
web-app/tests/jsconfig.json
Normal file
5
web-app/tests/jsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
},
|
||||
}
|
||||
34
web-app/tests/mock-server.test.js
Normal file
34
web-app/tests/mock-server.test.js
Normal 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
5076
web-app/tests/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
web-app/tests/package.json
Normal file
18
web-app/tests/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
web-app/tests/playwright.config.js
Normal file
12
web-app/tests/playwright.config.js
Normal 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,
|
||||
});
|
||||
12
web-app/tests/sample.e2e.js
Normal file
12
web-app/tests/sample.e2e.js
Normal 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
107
web-app/tests/util.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
101
web-app/tests/util/mock-server.js
Normal file
101
web-app/tests/util/mock-server.js
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user