Files
promptfoo--promptfoo/test/util/render.test.ts
T
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

589 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderEnvOnlyInObject, renderVarsInObject } from '../../src/util/render';
import { mockProcessEnv } from './utils';
describe('renderVarsInObject', () => {
beforeEach(() => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
});
afterEach(() => {
mockProcessEnv({ TEST_ENV_VAR: undefined });
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
});
it('should render environment variables in objects', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
const obj = { text: '{{ env.TEST_ENV_VAR }}' };
const rendered = renderVarsInObject(obj, {});
expect(rendered).toEqual({ text: 'env_value' });
});
it('should return object unchanged when no vars provided', async () => {
const obj = { text: '{{ variable }}', number: 42 };
const rendered = renderVarsInObject(obj);
expect(rendered).toEqual(obj);
});
it('should return object unchanged when vars is empty object', async () => {
const obj = { text: '{{ variable }}', number: 42 };
const rendered = renderVarsInObject(obj, {});
// Empty object {} is truthy, so templating still runs but with no variables
expect(rendered).toEqual({ text: '', number: 42 });
});
it('should return object unchanged when PROMPTFOO_DISABLE_TEMPLATING is true', async () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
const obj = { text: '{{ variable }}' };
const vars = { variable: 'test_value' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual(obj);
});
it('should render variables in string objects', async () => {
const obj = 'Hello {{ name }}!';
const vars = { name: 'World' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toBe('Hello World!');
});
it('should render variables in array objects', async () => {
const obj = ['{{ greeting }}', '{{ name }}', 42];
const vars = { greeting: 'Hello', name: 'World' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual(['Hello', 'World', 42]);
});
it('should render variables in nested arrays', async () => {
const obj = [
['{{ item1 }}', '{{ item2 }}'],
['static', '{{ item3 }}'],
];
const vars = { item1: 'first', item2: 'second', item3: 'third' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual([
['first', 'second'],
['static', 'third'],
]);
});
it('should render variables in nested objects', async () => {
const obj = {
level1: {
level2: {
text: '{{ variable }}',
number: 42,
},
array: ['{{ item }}'],
},
};
const vars = { variable: 'nested_value', item: 'array_item' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual({
level1: {
level2: {
text: 'nested_value',
number: 42,
},
array: ['array_item'],
},
});
});
it('should handle function objects by calling them with vars', async () => {
const mockFunction = vi.fn().mockReturnValue({ result: '{{ value }}' });
const vars = { value: 'function_result' };
const rendered = renderVarsInObject(mockFunction, vars);
expect(mockFunction).toHaveBeenCalledWith({ vars });
// Function result is NOT recursively templated because vars is not passed in recursive call
expect(rendered).toEqual({ result: '{{ value }}' });
});
it('should handle null values', async () => {
const obj = null;
const vars = { variable: 'test' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toBeNull();
});
it('should handle undefined values', async () => {
const obj = undefined;
const vars = { variable: 'test' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toBeUndefined();
});
it('should handle primitive number values', async () => {
const obj = 42;
const vars = { variable: 'test' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toBe(42);
});
it('should handle primitive boolean values', async () => {
const obj = true;
const vars = { variable: 'test' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toBe(true);
});
it('should handle objects with null properties', async () => {
const obj = { nullProp: null, text: '{{ variable }}' };
const vars = { variable: 'test_value' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual({ nullProp: null, text: 'test_value' });
});
it('should handle mixed type objects', async () => {
const obj = {
string: '{{ text }}',
number: 42,
boolean: true,
nullValue: null,
array: ['{{ item }}', 123],
nested: {
deep: '{{ deep_value }}',
},
};
const vars = { text: 'rendered', item: 'array_item', deep_value: 'deep_rendered' };
const rendered = renderVarsInObject(obj, vars);
expect(rendered).toEqual({
string: 'rendered',
number: 42,
boolean: true,
nullValue: null,
array: ['array_item', 123],
nested: {
deep: 'deep_rendered',
},
});
});
it('should handle function that returns complex object structure', async () => {
const complexFunction = vi.fn().mockReturnValue({
data: {
items: ['{{ item1 }}', '{{ item2 }}'],
metadata: { value: '{{ meta }}' },
},
});
const vars = { item1: 'first', item2: 'second', meta: 'metadata_value' };
const rendered = renderVarsInObject(complexFunction, vars);
expect(complexFunction).toHaveBeenCalledWith({ vars });
// Function result is NOT recursively templated because vars is not passed in recursive call
expect(rendered).toEqual({
data: {
items: ['{{ item1 }}', '{{ item2 }}'],
metadata: { value: '{{ meta }}' },
},
});
});
});
describe('renderEnvOnlyInObject', () => {
beforeEach(() => {
mockProcessEnv({ TEST_ENV_VAR: undefined });
mockProcessEnv({ AZURE_ENDPOINT: undefined });
mockProcessEnv({ API_VERSION: undefined });
mockProcessEnv({ PORT: undefined });
mockProcessEnv({ API_HOST: undefined });
mockProcessEnv({ BASE_URL: undefined });
mockProcessEnv({ EMPTY_VAR: undefined });
mockProcessEnv({ SPECIAL_CHARS: undefined });
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
});
afterEach(() => {
mockProcessEnv({ TEST_ENV_VAR: undefined });
mockProcessEnv({ AZURE_ENDPOINT: undefined });
mockProcessEnv({ API_VERSION: undefined });
mockProcessEnv({ PORT: undefined });
mockProcessEnv({ API_HOST: undefined });
mockProcessEnv({ BASE_URL: undefined });
mockProcessEnv({ EMPTY_VAR: undefined });
mockProcessEnv({ SPECIAL_CHARS: undefined });
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
});
describe('Basic rendering', () => {
it('should render simple dot notation env vars', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}')).toBe('env_value');
});
it('should render bracket notation with single quotes', async () => {
mockProcessEnv({ ['VAR-WITH-DASH']: 'dash_value' });
expect(renderEnvOnlyInObject("{{ env['VAR-WITH-DASH'] }}")).toBe('dash_value');
});
it('should render bracket notation with double quotes', async () => {
mockProcessEnv({ ['VAR_NAME']: 'value' });
expect(renderEnvOnlyInObject('{{ env["VAR_NAME"] }}')).toBe('value');
});
it('should handle whitespace variations', async () => {
mockProcessEnv({ TEST: 'value' });
expect(renderEnvOnlyInObject('{{env.TEST}}')).toBe('value');
expect(renderEnvOnlyInObject('{{ env.TEST }}')).toBe('value');
expect(renderEnvOnlyInObject('{{ env.TEST}}')).toBe('value');
expect(renderEnvOnlyInObject('{{env.TEST }}')).toBe('value');
});
it('should render empty string env vars', async () => {
mockProcessEnv({ EMPTY_VAR: '' });
expect(renderEnvOnlyInObject('{{ env.EMPTY_VAR }}')).toBe('');
});
it('should render env vars with special characters in value', async () => {
mockProcessEnv({ SPECIAL_CHARS: 'value with spaces & $pecial chars!' });
expect(renderEnvOnlyInObject('{{ env.SPECIAL_CHARS }}')).toBe(
'value with spaces & $pecial chars!',
);
});
});
describe('Filters and expressions (NEW functionality)', () => {
it('should support default filter with fallback', async () => {
mockProcessEnv({ EXISTING: 'exists' });
expect(renderEnvOnlyInObject("{{ env.EXISTING | default('fallback') }}")).toBe('exists');
// NEW: When env var doesn't exist but has default filter, Nunjucks renders it
expect(renderEnvOnlyInObject("{{ env.NONEXISTENT | default('fallback') }}")).toBe('fallback');
});
it('should support upper filter', async () => {
mockProcessEnv({ LOWERCASE: 'lowercase' });
expect(renderEnvOnlyInObject('{{ env.LOWERCASE | upper }}')).toBe('LOWERCASE');
});
it('should support lower filter', async () => {
mockProcessEnv({ UPPERCASE: 'UPPERCASE' });
expect(renderEnvOnlyInObject('{{ env.UPPERCASE | lower }}')).toBe('uppercase');
});
it('should support chained filters', async () => {
mockProcessEnv({ TEST: 'test' });
expect(renderEnvOnlyInObject("{{ env.TEST | default('x') | upper }}")).toBe('TEST');
});
it('should support complex filter expressions', async () => {
mockProcessEnv({ PORT: '8080' });
expect(renderEnvOnlyInObject('{{ env.PORT | int }}')).toBe('8080');
});
it('should handle filter with closing brace in argument', async () => {
mockProcessEnv({ VAR: 'value' });
// This is a tricky case: the default value contains }
expect(renderEnvOnlyInObject("{{ env.VAR | default('}') }}")).toBe('value');
});
});
describe('Preservation of non-env templates', () => {
it('should preserve vars templates', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}, {{ vars.myVar }}')).toBe(
'env_value, {{ vars.myVar }}',
);
});
it('should preserve prompt templates', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}, {{ prompt }}')).toBe(
'env_value, {{ prompt }}',
);
});
it('should preserve multiple non-env templates', async () => {
mockProcessEnv({ API_HOST: 'api.example.com' });
const template =
'Host: {{ env.API_HOST }}, Message: {{ vars.msg }}, Context: {{ context }}, Prompt: {{ prompt }}';
const expected =
'Host: api.example.com, Message: {{ vars.msg }}, Context: {{ context }}, Prompt: {{ prompt }}';
expect(renderEnvOnlyInObject(template)).toBe(expected);
});
it('should preserve templates with filters on non-env vars', async () => {
expect(renderEnvOnlyInObject("{{ vars.name | default('Guest') }}")).toBe(
"{{ vars.name | default('Guest') }}",
);
});
it('should preserve env templates in _conversation runtime vars', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
const config = {
tests: [
{
vars: {
_conversation: [
{
input: 'Tell me a secret',
output: 'The answer is {{ env.TEST_ENV_VAR }}',
},
],
regularVar: '{{ env.TEST_ENV_VAR }}',
},
},
],
};
expect(renderEnvOnlyInObject(config)).toEqual({
tests: [
{
vars: {
_conversation: [
{
input: 'Tell me a secret',
output: 'The answer is {{ env.TEST_ENV_VAR }}',
},
],
regularVar: 'env_value',
},
},
],
});
});
});
describe('Undefined env vars', () => {
it('should preserve template if env var does not exist', async () => {
expect(renderEnvOnlyInObject('{{ env.NONEXISTENT }}')).toBe('{{ env.NONEXISTENT }}');
});
it('should preserve bracket notation if env var does not exist', async () => {
expect(renderEnvOnlyInObject("{{ env['MISSING'] }}")).toBe("{{ env['MISSING'] }}");
});
});
describe('Complex data structures', () => {
it('should work with nested objects', async () => {
mockProcessEnv({ LEVEL1: 'value1' });
mockProcessEnv({ LEVEL2: 'value2' });
const obj = {
level1: {
level2: {
env1: '{{ env.LEVEL1 }}',
env2: '{{ env.LEVEL2 }}',
vars: '{{ vars.test }}',
},
},
};
expect(renderEnvOnlyInObject(obj)).toEqual({
level1: {
level2: {
env1: 'value1',
env2: 'value2',
vars: '{{ vars.test }}',
},
},
});
});
it('should work with arrays', async () => {
mockProcessEnv({ ENV1: 'value1' });
mockProcessEnv({ ENV2: 'value2' });
const arr = ['{{ env.ENV1 }}', '{{ vars.test }}', '{{ env.ENV2 }}', 42];
expect(renderEnvOnlyInObject(arr)).toEqual(['value1', '{{ vars.test }}', 'value2', 42]);
});
it('should work with mixed nested structures', async () => {
mockProcessEnv({ API_KEY: 'secret123' });
const config = {
api: {
key: '{{ env.API_KEY }}',
endpoints: ['{{ env.BASE_URL }}/users', '{{ env.BASE_URL }}/posts'],
},
request: {
body: { message: '{{ vars.message }}' },
headers: { authorization: 'Bearer {{ env.API_KEY }}' },
},
};
const rendered = renderEnvOnlyInObject(config);
expect(rendered).toEqual({
api: {
key: 'secret123',
endpoints: ['{{ env.BASE_URL }}/users', '{{ env.BASE_URL }}/posts'],
},
request: {
body: { message: '{{ vars.message }}' },
headers: { authorization: 'Bearer secret123' },
},
});
});
});
describe('Primitive types', () => {
it('should handle null', async () => {
expect(renderEnvOnlyInObject(null)).toBeNull();
});
it('should handle undefined', async () => {
expect(renderEnvOnlyInObject(undefined)).toBeUndefined();
});
it('should handle numbers', async () => {
expect(renderEnvOnlyInObject(42)).toBe(42);
});
it('should handle booleans', async () => {
expect(renderEnvOnlyInObject(true)).toBe(true);
expect(renderEnvOnlyInObject(false)).toBe(false);
});
});
describe('Error handling', () => {
it('should preserve template on Nunjucks render error', async () => {
mockProcessEnv({ TEST: 'value' });
// Malformed filter that would cause Nunjucks error
const template = '{{ env.TEST | nonexistent_filter }}';
const rendered = renderEnvOnlyInObject(template);
// Should preserve the template if rendering fails
expect(rendered).toBe(template);
});
});
describe('PROMPTFOO_DISABLE_TEMPLATING flag', () => {
it('should return unchanged when flag is set', async () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}')).toBe('{{ env.TEST_ENV_VAR }}');
});
it('should return unchanged objects when flag is set', async () => {
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
mockProcessEnv({ TEST: 'value' });
const obj = { key: '{{ env.TEST }}' };
expect(renderEnvOnlyInObject(obj)).toEqual({ key: '{{ env.TEST }}' });
});
});
describe('Real-world scenarios', () => {
it('should handle Azure provider config with mixed templates', async () => {
mockProcessEnv({ AZURE_ENDPOINT: 'test.openai.azure.com' });
mockProcessEnv({ API_VERSION: '2024-02-15' });
const config = {
apiHost: '{{ env.AZURE_ENDPOINT }}',
apiVersion: '{{ env.API_VERSION }}',
body: {
message: '{{ vars.userMessage }}',
user: '{{ vars.userId }}',
},
};
expect(renderEnvOnlyInObject(config)).toEqual({
apiHost: 'test.openai.azure.com',
apiVersion: '2024-02-15',
body: {
message: '{{ vars.userMessage }}',
user: '{{ vars.userId }}',
},
});
});
it('should handle HTTP provider with runtime vars', async () => {
mockProcessEnv({ BASE_URL: 'https://api.example.com' });
const config = {
url: '{{ env.BASE_URL }}/query',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer {{ env.API_TOKEN }}',
},
body: {
query: '{{ vars.userQuery }}',
context: '{{ vars.context }}',
},
};
const rendered = renderEnvOnlyInObject(config);
expect(rendered).toEqual({
url: 'https://api.example.com/query',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer {{ env.API_TOKEN }}', // Undefined, preserved
},
body: {
query: '{{ vars.userQuery }}', // Runtime vars preserved
context: '{{ vars.context }}', // Runtime vars preserved
},
});
});
it('should handle complex provider config with filters', async () => {
mockProcessEnv({ API_HOST: 'api.example.com' });
mockProcessEnv({ PORT: '8080' });
const config = {
baseUrl: "{{ env.API_HOST | default('localhost') }}:{{ env.PORT }}",
timeout: '{{ env.TIMEOUT | default(30000) }}',
request: {
body: '{{ vars.payload }}',
},
};
const rendered = renderEnvOnlyInObject(config);
expect(rendered).toEqual({
baseUrl: 'api.example.com:8080',
timeout: '30000', // TIMEOUT undefined, but default filter renders it
request: {
body: '{{ vars.payload }}',
},
});
});
});
describe('Edge cases', () => {
it('should handle multiple env vars in same string', async () => {
mockProcessEnv({ HOST: 'example.com' });
mockProcessEnv({ PORT: '8080' });
expect(renderEnvOnlyInObject('{{ env.HOST }}:{{ env.PORT }}')).toBe('example.com:8080');
});
it('should handle env vars in middle of text', async () => {
mockProcessEnv({ NAME: 'World' });
expect(renderEnvOnlyInObject('Hello {{ env.NAME }}!')).toBe('Hello World!');
});
it('should handle templates with newlines', async () => {
mockProcessEnv({ VAR: 'value' });
expect(
renderEnvOnlyInObject(`Line 1: {{ env.VAR }}
Line 2: {{ vars.test }}`),
).toBe(`Line 1: value
Line 2: {{ vars.test }}`);
});
it('should render env vars in strings longer than 50000 chars', async () => {
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
const padding = 'x'.repeat(60000);
const template = `${padding} {{ env.TEST_ENV_VAR }} ${padding}`;
expect(template.length).toBeGreaterThan(50000);
expect(renderEnvOnlyInObject(template)).toBe(`${padding} env_value ${padding}`);
});
it('should preserve non-env templates in long strings', async () => {
const padding = 'x'.repeat(60000);
const template = `${padding} {{ vars.myVar }} ${padding}`;
expect(renderEnvOnlyInObject(template)).toBe(template);
});
it('should handle long strings with mixed env and non-env templates', async () => {
mockProcessEnv({ HOST: 'example.com' });
const padding = 'x'.repeat(60000);
const template = `${padding} {{ env.HOST }} {{ vars.test }} ${padding}`;
expect(renderEnvOnlyInObject(template)).toBe(
`${padding} example.com {{ vars.test }} ${padding}`,
);
});
it('should handle long strings with no templates at all', async () => {
const longString = 'a'.repeat(100000);
expect(renderEnvOnlyInObject(longString)).toBe(longString);
});
it('should not confuse env in other contexts', async () => {
mockProcessEnv({ TEST: 'value' });
// Should not match "environment" or other words containing "env"
expect(renderEnvOnlyInObject('environment {{ vars.test }}')).toBe(
'environment {{ vars.test }}',
);
});
});
});