Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

498 lines
21 KiB
TypeScript

import nunjucks from 'nunjucks';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import cliState from '../../src/cliState';
import {
analyzeTemplateReference,
extractVariablesFromTemplate,
extractVariablesFromTemplates,
getNunjucksEngine,
templateReferencesVariable,
} from '../../src/util/templates';
import { mockProcessEnv } from './utils';
describe('extractVariablesFromTemplate', () => {
it('should extract simple variables', () => {
const template = 'Hello {{ name }}, welcome to {{ place }}!';
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'place']);
});
it('should extract variables without spaces', () => {
const template = 'Hello {{name}}, welcome to {{place}}!';
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'place']);
});
it('should extract variables with dot notation', () => {
const template = 'Hello {{ user.name }}, your score is {{ game.score }}!';
expect(extractVariablesFromTemplate(template)).toEqual(['user.name', 'game.score']);
});
it('should extract variables with underscores', () => {
const template = 'Your order {{ order_id }} will arrive on {{ delivery_date }}.';
expect(extractVariablesFromTemplate(template)).toEqual(['order_id', 'delivery_date']);
});
it('should extract variables with numbers', () => {
const template = 'Player1: {{ player1 }}, Player2: {{ player2 }}';
expect(extractVariablesFromTemplate(template)).toEqual(['player1', 'player2']);
});
it('should extract variables used in filters', () => {
const template = '{{ name | capitalize }} - {{ date | date("yyyy-MM-dd") }}';
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'date']);
});
it('should extract variables used in complex expressions', () => {
const template = '{% if user.age > 18 %}Welcome, {{ user.name }}!{% endif %}';
expect(extractVariablesFromTemplate(template)).toEqual(['user.age', 'user.name']);
});
it('should extract variables from for loops', () => {
const template = '{% for item in items %}{{ item.name }}{% endfor %}';
expect(extractVariablesFromTemplate(template)).toEqual(['item.name', 'items']);
});
it('should extract variables with multiple occurrences', () => {
const template = '{{ name }} {{ age }} {{ name }}';
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'age']);
});
it('should not extract variables from comments', () => {
const template = '{# This is a comment with {{ variable }} #}{{ actual_variable }}';
expect(extractVariablesFromTemplate(template)).toEqual(['actual_variable']);
});
it('should handle empty templates', () => {
const template = '';
expect(extractVariablesFromTemplate(template)).toEqual([]);
});
it('should handle templates without variables', () => {
const template = 'This is a static template without variables.';
expect(extractVariablesFromTemplate(template)).toEqual([]);
});
});
describe('extractVariablesFromTemplates', () => {
it('should extract variables from multiple templates', () => {
const templates = [
'Hello {{ name }}, welcome to {{ place }}!',
'{{ user.age }} years old',
'{% for item in items %}{{ item.name }}{% endfor %}',
];
const result = extractVariablesFromTemplates(templates);
expect(result).toEqual(['name', 'place', 'user.age', 'item.name', 'items']);
});
it('should handle empty array of templates', () => {
const templates: string[] = [];
const result = extractVariablesFromTemplates(templates);
expect(result).toEqual([]);
});
it('should deduplicate variables across templates', () => {
const templates = ['Hello {{ name }}!', 'Welcome, {{ name }}!', '{{ age }} years old'];
const result = extractVariablesFromTemplates(templates);
expect(result).toEqual(['name', 'age']);
});
});
describe('templateReferencesVariable', () => {
it.each<[string, string]>([
['basic symbol read', '{{ _conversation[0].output }}'],
['filter input', '{{ _conversation | length }}'],
['chained filters', '{{ _conversation | first | length }}'],
['if condition', '{% if _conversation %}yes{% endif %}'],
['elif condition', '{% if false %}no{% elif _conversation %}yes{% endif %}'],
['ternary true branch', '{{ "yes" if _conversation else "no" }}'],
['ternary condition', '{{ first if _conversation else second }}'],
['ternary else branch', '{{ a if x else _conversation }}'],
['logical expression', '{{ _conversation and foo }}'],
['not expression', '{{ not _conversation }}'],
['comparison', '{{ _conversation == [] }}'],
['less-than', '{{ x < _conversation }}'],
['for loop iterable', '{% for turn in _conversation %}{{ turn.output }}{% endfor %}'],
['function argument', '{{ summarize(_conversation) }}'],
['keyword argument value', '{{ fn(arg=_conversation) }}'],
['filter argument', '{{ input | default(_conversation) }}'],
['is test with call arg', '{{ foo is divisibleby(_conversation) }}'],
['is test function argument value', '{{ foo is _conversation(_conversation) }}'],
['bracket index lookup', '{{ obj[_conversation] }}'],
['array literal member', '{{ [_conversation, other] }}'],
['group expression', '{{ (_conversation) }}'],
['negated expression', '{{ -_conversation }}'],
['concat', '{{ "a" ~ _conversation ~ "b" }}'],
['dict literal value', '{{ {"history": _conversation}["history"][0].output }}'],
['set value binding', '{% set history = _conversation %}{{ history }}'],
['set block binding', '{% set history %}{{ _conversation }}{% endset %}{{ history }}'],
['macro body free variable', '{% macro render() %}{{ _conversation }}{% endmacro %}'],
['macro default value', '{% macro render(x=_conversation) %}{{ x }}{% endmacro %}'],
[
'macro default value with body ref',
'{% macro render(x=_conversation) %}{{ _conversation }}{% endmacro %}',
],
[
'macro self-referential default value',
'{% macro render(_conversation=_conversation) %}{{ _conversation[0].output }}{% endmacro %}{{ render() }}',
],
['include expression', '{% include _conversation %}'],
['extends expression', '{% extends _conversation %}{% block b %}{% endblock %}'],
['from-import template expression', '{% from template_name import foo %}{{ _conversation }}'],
['block body reference', '{% block body %}{{ _conversation }}{% endblock %}'],
[
'call block body reference',
'{% macro m() %}{{ caller() }}{% endmacro %}{% call m() %}{{ _conversation }}{% endcall %}',
],
[
'call block body reference with unrelated caller arg',
'{% macro m() %}{{ caller("local") }}{% endmacro %}{% call(x) m() %}{{ _conversation }}{% endcall %}',
],
[
'call block caller default value',
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(x=_conversation) m() %}{{ x[0].output }}{% endcall %}',
],
[
'call block caller self-referential default value',
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(_conversation=_conversation) m() %}{{ _conversation[0].output }}{% endcall %}',
],
['asyncEach iterable', '{% asyncEach turn in _conversation %}{{ turn.output }}{% endeach %}'],
['asyncAll iterable', '{% asyncAll turn in _conversation %}{{ turn.output }}{% endall %}'],
])('detects a real reference in %s', (_label, template) => {
expect(templateReferencesVariable(template, '_conversation')).toBe(true);
});
it.each<[string, string]>([
['substring in plain text', 'Summarize the pre_conversation_context for {{ question }}'],
['substring in symbol name', '{{ pre_conversation_context }}'],
['string literal', '{{ "_conversation" }}'],
['comment mention', '{# _conversation #}{{ question }}'],
['property of another object', '{{ foo._conversation }}'],
['bracket lookup on another object', '{{ foo["_conversation"] }}'],
['dict literal key', '{{ {_conversation: input} }}'],
['filter name', '{{ input | _conversation }}'],
['is test name', '{{ input is _conversation }}'],
['is test function name', '{{ input is _conversation(foo) }}'],
['raw block', '{% raw %}{{ _conversation }}{% endraw %}'],
['for-loop target shadow', '{% for _conversation in items %}{{ _conversation }}{% endfor %}'],
['macro argument shadow', '{% macro render(_conversation) %}{{ _conversation }}{% endmacro %}'],
['set value shadow', '{% set _conversation = [] %}{{ _conversation }}'],
['set block shadow', '{% set _conversation %}local{% endset %}{{ _conversation }}'],
[
'set inside loop body',
'{% for i in range(3) %}{% set _conversation = [] %}{{ _conversation }}{% endfor %}',
],
['import target shadow', '{% import "x.njk" as _conversation %}{{ _conversation }}'],
['from-import alias binding', '{% from "x.njk" import foo as _conversation %}{{ question }}'],
['block name label', '{% block _conversation %}hi{% endblock %}'],
[
'for-loop tuple target shadow',
'{% for k, _conversation in items %}{{ _conversation }}{% endfor %}',
],
[
'nested for target shadow',
'{% for _conversation in outer %}{% for x in _conversation %}{{ _conversation }}{% endfor %}{% endfor %}',
],
['set tuple target shadow', '{% set a, _conversation = pair %}{{ _conversation }}'],
[
'macro keyword-default param name shadow',
'{% macro render(_conversation=something) %}{{ _conversation }}{% endmacro %}',
],
[
'macro keyword default left-to-right shadow',
'{% macro render(_conversation=[], history=_conversation) %}{{ history | length }}{% endmacro %}{{ render() }}',
],
[
'macro positional param used by keyword default',
'{% macro render(_conversation, history=_conversation) %}{{ history }}{% endmacro %}',
],
[
'call block caller argument shadow',
'{% macro m() %}{{ caller("local") }}{% endmacro %}{% call(_conversation) m() %}{{ _conversation }}{% endcall %}',
],
[
'call block caller keyword default left-to-right shadow',
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(_conversation=[], history=_conversation) m() %}{{ history | length }}{% endcall %}',
],
[
'asyncEach target shadow',
'{% asyncEach _conversation in items %}{{ _conversation }}{% endeach %}',
],
[
'asyncAll target shadow',
'{% asyncAll _conversation in items %}{{ _conversation }}{% endall %}',
],
])('ignores %s', (_label, template) => {
expect(templateReferencesVariable(template, '_conversation')).toBe(false);
});
it('handles from-import aliases without shadowing source names', () => {
expect(templateReferencesVariable('{% from "x.njk" import foo %}{{ foo }}', 'foo')).toBe(false);
expect(
templateReferencesVariable('{% from "x.njk" import foo as helper %}{{ helper }}', 'helper'),
).toBe(false);
expect(
templateReferencesVariable('{% from "x.njk" import foo as helper %}{{ foo }}', 'foo'),
).toBe(true);
expect(
templateReferencesVariable(
'{% from templateName import foo as helper %}{{ helper }}',
'templateName',
),
).toBe(true);
});
it('returns false for empty variable names', () => {
expect(templateReferencesVariable('{{ _conversation }}', '')).toBe(false);
});
it('returns false for templates that do not mention the variable at all', () => {
expect(templateReferencesVariable('{{ question }}', '_conversation')).toBe(false);
expect(templateReferencesVariable('', '_conversation')).toBe(false);
});
it('falls back to a conservative match when the template fails to parse', () => {
// Old substring-based code would force serial execution here; the new
// AST-based code must preserve that safety envelope when parsing fails
// rather than silently returning false.
expect(templateReferencesVariable('{{ _conversation', '_conversation')).toBe(true);
expect(templateReferencesVariable('{{ _conversation[0].output {% if %}', '_conversation')).toBe(
true,
);
expect(templateReferencesVariable('{{ _conversation }}{% endif %}', '_conversation')).toBe(
true,
);
});
it('does not throw on malformed templates', () => {
expect(() => templateReferencesVariable('{{ _conversation', '_conversation')).not.toThrow();
expect(() => templateReferencesVariable('{% for %}', '_conversation')).not.toThrow();
});
});
describe('analyzeTemplateReference', () => {
it('reports parsed=true when the template parses', () => {
expect(analyzeTemplateReference('{{ _conversation }}', '_conversation')).toEqual({
referenced: true,
parsed: true,
});
expect(analyzeTemplateReference('{{ question }}', '_conversation')).toEqual({
referenced: false,
parsed: true,
});
});
it('reports parsed=false and conservative referenced=true on parse failure', () => {
expect(analyzeTemplateReference('{{ _conversation', '_conversation')).toEqual({
referenced: true,
parsed: false,
});
});
it('reports parsed=true with referenced=false when the variable is not mentioned', () => {
// The textual fast path returns parsed=true because nothing was actually parsed
// — no parse failure to surface.
expect(analyzeTemplateReference('{{ question }}', '_conversation')).toEqual({
referenced: false,
parsed: true,
});
});
});
describe('getNunjucksEngine', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetModules();
mockProcessEnv({ ...originalEnv }, { clear: true });
});
afterEach(() => {
mockProcessEnv(originalEnv, { clear: true });
});
it('should return a nunjucks environment by default', () => {
const engine = getNunjucksEngine();
expect(engine).toBeInstanceOf(nunjucks.Environment);
expect(engine.renderString('Hello {{ name }}', { name: 'World' })).toBe('Hello World');
});
it('should return a simple render function when PROMPTFOO_DISABLE_TEMPLATING is set', () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
const engine = getNunjucksEngine();
expect(engine.renderString('Hello {{ name }}', { name: 'World' })).toBe('Hello {{ name }}');
});
it('should return a nunjucks environment when isGrader is true, regardless of PROMPTFOO_DISABLE_TEMPLATING', () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
const engine = getNunjucksEngine({}, false, true);
expect(engine).toBeInstanceOf(nunjucks.Environment);
expect(engine.renderString('Hello {{ name }}', { name: 'Grader' })).toBe('Hello Grader');
});
it('should use nunjucks when isGrader is true, even if PROMPTFOO_DISABLE_TEMPLATING is set', () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
const engine = getNunjucksEngine({}, false, true);
expect(engine).toBeInstanceOf(nunjucks.Environment);
expect(engine.renderString('Hello {{ name }}', { name: 'Grader' })).toBe('Hello Grader');
});
it('should add custom filters when provided', () => {
const customFilters = {
uppercase: (str: string) => str.toUpperCase(),
add: (a: number, b: number) => (a + b).toString(),
};
const engine = getNunjucksEngine(customFilters);
expect(engine.renderString('{{ "hello" | uppercase }}', {})).toBe('HELLO');
expect(engine.renderString('{{ 5 | add(3) }}', {})).toBe('8');
});
it('should add built-in load filter for JSON parsing', () => {
const engine = getNunjucksEngine();
const jsonString = '{"name": "test", "value": 42}';
const template = `{{ '${jsonString}' | load }}`;
const result = engine.renderString(template, {});
expect(result).toBe('[object Object]');
// Test that the filter actually parses JSON correctly
const jsonData = '{"key": "value"}';
const loadFilter = engine.getFilter('load');
expect(loadFilter).toBeDefined();
expect(loadFilter(jsonData)).toEqual({ key: 'value' });
});
it('should handle load filter with invalid JSON', () => {
const engine = getNunjucksEngine();
const loadFilter = engine.getFilter('load');
expect(loadFilter).toBeDefined();
expect(() => loadFilter('invalid json')).toThrow('Unexpected token');
});
it('should add environment variables as globals under "env"', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
});
it('should throw an error when throwOnUndefined is true and a variable is undefined', () => {
const engine = getNunjucksEngine({}, true);
expect(() => {
engine.renderString('{{ undefined_var }}', {});
}).toThrow(/attempted to output null or undefined value/);
});
it('should not throw an error when throwOnUndefined is false and a variable is undefined', () => {
const engine = getNunjucksEngine({}, false);
expect(() => {
engine.renderString('{{ undefined_var }}', {});
}).not.toThrow();
});
it('should respect all parameters when provided', () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
const customFilters = {
double: (n: number) => (n * 2).toString(),
};
const engine = getNunjucksEngine(customFilters, true, true);
expect(engine).toBeInstanceOf(nunjucks.Environment);
expect(engine.renderString('{{ 5 | double }}', {})).toBe('10');
expect(() => {
engine.renderString('{{ undefined_var }}', {});
}).toThrow(/attempted to output null or undefined value/);
});
describe('environment variables as globals', () => {
it('should add environment variables as globals by default', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
});
it('should merge cliState.config.env with process.env', () => {
const initialConfig = { ...cliState.config };
cliState.config = {
env: {
PROCESS_VAR: 'overridden_value',
CONFIG_VAR: 'config_value',
},
};
const engine = getNunjucksEngine();
const rendered = engine.renderString('{{ env.PROCESS_VAR }}', {});
expect(rendered).toBe('overridden_value');
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
cliState.config = initialConfig;
});
it('should handle undefined cliState.config', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
});
it('should handle undefined cliState.config.env', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
});
it('should disable process.env but allow config env variables when PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS is true', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS: 'true' });
const initialConfig = { ...cliState.config };
cliState.config = {
env: {
CONFIG_VAR: 'config_value',
},
};
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('');
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
cliState.config = initialConfig;
});
it('should disable process.env but allow config env variables when PROMPTFOO_SELF_HOSTED is true', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
mockProcessEnv({ PROMPTFOO_SELF_HOSTED: 'true' });
const initialConfig = { ...cliState.config };
cliState.config = {
env: {
CONFIG_VAR: 'config_value',
},
};
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('');
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
cliState.config = initialConfig;
});
it('should handle empty config env object', () => {
mockProcessEnv({ TEST_VAR: 'test_value' });
const initialConfig = { ...cliState.config };
cliState.config = {
env: {},
};
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
cliState.config = initialConfig;
});
it('should prioritize config env variables over process.env when both exist', () => {
mockProcessEnv({ SHARED_VAR: 'process_value' });
const initialConfig = { ...cliState.config };
cliState.config = {
env: {
SHARED_VAR: 'config_value',
},
};
const engine = getNunjucksEngine();
expect(engine.renderString('{{ env.SHARED_VAR }}', {})).toBe('config_value');
cliState.config = initialConfig;
});
});
});