Files
wehub-resource-sync 0d3cb498a3
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

658 lines
25 KiB
TypeScript

/**
* Regression tests for recently fixed bugs (0.119.x and beyond).
*
* These tests verify that bugs fixed in recent releases don't regress.
*
* Bug categories tested:
* - File reference handling in assertions and vars
* - Function provider support
* - Dynamic value loading
*
* @see docs/plans/smoke-tests.md for the full bug documentation
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// Path to the built CLI binary
const CLI_PATH = path.resolve(__dirname, '../../dist/src/main.js');
const ROOT_DIR = path.resolve(__dirname, '../..');
const FIXTURES_DIR = path.resolve(__dirname, 'fixtures');
const OUTPUT_DIR = path.resolve(__dirname, '.temp-output-recent');
/**
* Helper to run the CLI and capture output
*/
function runCli(
args: string[],
options: { cwd?: string; expectError?: boolean; env?: NodeJS.ProcessEnv; timeout?: number } = {},
): { stdout: string; stderr: string; exitCode: number } {
const result = spawnSync('node', [CLI_PATH, ...args], {
cwd: options.cwd || ROOT_DIR,
encoding: 'utf-8',
env: { ...process.env, ...options.env, NO_COLOR: '1' },
timeout: options.timeout || 60000,
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.status ?? 1,
};
}
describe('Recent Bug Regression Tests', () => {
beforeAll(() => {
// Verify the built binary exists
if (!fs.existsSync(CLI_PATH)) {
throw new Error(`Built CLI not found at ${CLI_PATH}. Run 'npm run build' first.`);
}
// Create output directory for test artifacts
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
});
afterAll(() => {
// Clean up output directory
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
}
});
describe('File Reference Handling', () => {
describe('#6253 - file:// references in assertion values', () => {
it('uses script output for file:// references in assertion values', () => {
// Bug #6253: file:// references in assertion values should execute
// the script and use its return value
const configPath = path.join(FIXTURES_DIR, 'configs/file-ref-assertion-value.yaml');
const outputPath = path.join(OUTPUT_DIR, 'file-ref-assertion-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Error loading');
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
});
});
describe('#6393 - file:// references for tests loading', () => {
it('loads tests from external YAML file with vars', () => {
// Bug #6393: file:// references should be preserved for runtime loading
const configPath = path.join(FIXTURES_DIR, 'configs/file-ref-vars.yaml');
const outputPath = path.join(OUTPUT_DIR, 'file-ref-vars-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Error loading');
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
expect(parsed.results.results[0].response.output).toContain('LoadedFromExternalFile');
});
});
describe('#7334 - dynamic vars not resolved in assertion context.vars', () => {
it('resolves file:// vars before passing to assertion functions', () => {
// Bug #7334: Dynamic variables with file:// prefix were resolved in prompts
// but when passed to JavaScript assertion functions via context.vars,
// they contained the raw file path instead of the resolved value.
const configPath = path.join(FIXTURES_DIR, 'configs/dynamic-var-assertion-7334.yaml');
const outputPath = path.join(OUTPUT_DIR, 'dynamic-var-assertion-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Error');
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
// The assertion should pass because context.vars.DYNAMIC_VAR
// contains the resolved ISO date, not the file:// path
expect(parsed.results.results[0].success).toBe(true);
// Check the individual assertion result (componentResults), not the aggregate reason
const componentResult = parsed.results.results[0].gradingResult.componentResults[0];
expect(componentResult.pass).toBe(true);
expect(componentResult.reason).toContain('correctly resolved');
expect(componentResult.reason).not.toContain('file://');
});
});
describe('#7861 - llm-rubric in defaultTest resolves test vars', () => {
it('renders defaultTest llm-rubric templates with each test case vars before grading', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/defaulttest-llm-rubric-vars.yaml');
const outputPath = path.join(OUTPUT_DIR, 'defaulttest-llm-rubric-vars-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Error');
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
const results = parsed.results.results;
expect(results).toHaveLength(2);
for (const [index, expectedValue] of ['hello world', 'goodbye moon'].entries()) {
const result = results[index];
const componentResult = result.gradingResult.componentResults[0];
expect(result.success).toBe(true);
expect(componentResult.pass).toBe(true);
expect(componentResult.assertion.value).toBe(
'Does the output correctly reference the input: {{myVar}}?',
);
expect(componentResult.metadata.renderedAssertionValue).toBe(
`Does the output correctly reference the input: ${expectedValue}?`,
);
expect(componentResult.metadata.renderedGradingPrompt).toContain(expectedValue);
expect(componentResult.metadata.renderedGradingPrompt).not.toContain('{{myVar}}');
expect(componentResult.reason).toContain(expectedValue);
expect(componentResult.reason).not.toContain('{{myVar}}');
}
});
});
});
describe('Provider Support', () => {
describe('#6174 - function providers in defaultTest', () => {
it('supports function providers in defaultTest.options.provider', () => {
// Bug #6174: function providers should work in defaultTest
const configPath = path.join(FIXTURES_DIR, 'configs/function-provider-defaulttest.yaml');
const outputPath = path.join(OUTPUT_DIR, 'function-provider-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('is not a function');
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
});
});
describe('#9383 - multiple CallApiFunction providers collapsed by dedupe', () => {
it('preserves every function provider when combineConfigs dedupes', () => {
// Bug #9383: JSON.stringify(fn) returns undefined, so combineConfigs dedupe
// collapsed every function provider into a single entry. The eval should
// produce one result row per function provider (a, b, c).
const configPath = path.join(FIXTURES_DIR, 'configs/function-providers-9383.ts');
const outputPath = path.join(OUTPUT_DIR, 'function-providers-9383-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Error');
const parsed = JSON.parse(fs.readFileSync(outputPath, 'utf-8'));
const outputs = parsed.results.results.map((r: { response: { output: string } }) =>
r.response.output.trim(),
);
expect(outputs).toEqual(['a: hi', 'b: hi', 'c: hi']);
});
});
});
describe('Config Extends Feature', () => {
describe('Config inheritance', () => {
it('extends base config with additional settings', () => {
// Test that config extends feature works
const configPath = path.join(FIXTURES_DIR, 'configs/basic.yaml');
const outputPath = path.join(OUTPUT_DIR, 'config-extends-output.json');
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
});
});
});
describe('Assertion Edge Cases', () => {
describe('Multiple assertion types combined', () => {
it('handles multiple different assertion types in single test', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/multi-assertion.yaml');
const outputPath = path.join(OUTPUT_DIR, 'multi-assertion-output.json');
// Create a temporary config with multiple assertion types
const tempConfig = `
description: 'Test multiple assertion types'
providers:
- echo
prompts:
- 'Hello World 123'
tests:
- assert:
- type: contains
value: Hello
- type: contains
value: World
- type: regex
value: '\\d+'
- type: javascript
value: output.length > 5
`;
fs.writeFileSync(configPath, tempConfig);
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
// Should have 4 component results
expect(parsed.results.results[0].gradingResult.componentResults.length).toBe(4);
});
});
describe('Empty and null value handling', () => {
it('handles tests with empty vars gracefully', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/empty-vars.yaml');
const outputPath = path.join(OUTPUT_DIR, 'empty-vars-output.json');
// Create a temporary config with empty vars
const tempConfig = `
description: 'Test empty vars handling'
providers:
- echo
prompts:
- 'Static prompt'
tests:
- vars: {}
assert:
- type: contains
value: Static
`;
fs.writeFileSync(configPath, tempConfig);
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
});
});
});
describe('Transform Features', () => {
describe('Response transform with JSON path', () => {
it('extracts nested JSON values with transform', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/transform-json-path.yaml');
const outputPath = path.join(OUTPUT_DIR, 'transform-json-path-output.json');
// Create config that tests JSON path extraction
const tempConfig = `
description: 'Test JSON path transform'
providers:
- id: echo
transform: 'JSON.parse(output).value'
prompts:
- '{"value": "extracted", "other": "ignored"}'
tests:
- assert:
- type: equals
value: extracted
`;
fs.writeFileSync(configPath, tempConfig);
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
});
});
});
describe('Nunjucks Template Features', () => {
describe('Nunjucks filters in prompts', () => {
it('supports built-in Nunjucks filters', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/nunjucks-filters.yaml');
const outputPath = path.join(OUTPUT_DIR, 'nunjucks-filters-output.json');
// Create config that tests Nunjucks filters
const tempConfig = `
description: 'Test Nunjucks filters'
providers:
- echo
prompts:
- 'Hello {{ name | upper }}'
tests:
- vars:
name: world
assert:
- type: contains
value: WORLD
`;
fs.writeFileSync(configPath, tempConfig);
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results[0].success).toBe(true);
expect(parsed.results.results[0].response.output).toContain('WORLD');
});
});
describe('Nunjucks conditionals', () => {
it('supports Nunjucks if/else in prompts', () => {
const configPath = path.join(FIXTURES_DIR, 'configs/nunjucks-conditionals.yaml');
const outputPath = path.join(OUTPUT_DIR, 'nunjucks-conditionals-output.json');
// Create config that tests Nunjucks conditionals
const tempConfig = `
description: 'Test Nunjucks conditionals'
providers:
- echo
prompts:
- '{% if premium %}Premium user{% else %}Free user{% endif %}'
tests:
- vars:
premium: true
assert:
- type: contains
value: Premium
- vars:
premium: false
assert:
- type: contains
value: Free
`;
fs.writeFileSync(configPath, tempConfig);
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.results.results.length).toBe(2);
expect(parsed.results.results[0].success).toBe(true);
expect(parsed.results.results[1].success).toBe(true);
});
});
});
describe('Circular Reference Handling', () => {
// NOTE: This test uses a custom provider instead of the 'echo' provider.
// This is an intentional exception to smoke test guidelines because:
// - The echo provider cannot generate circular references in response metadata
// - We need to verify that circular reference objects are properly sanitized
// - The custom provider (circular-ref-provider.js) creates controlled circular
// structures that reproduce the exact bug scenario from GitHub issue #7266
describe('#7266 - Converting circular structure to JSON error', () => {
it('handles provider responses with circular references without crashing', () => {
// Bug #7266: When providers return data with circular references
// (e.g., leaked Timeout objects with _idlePrev/_idleNext),
// saving results to DB would fail with:
// "TypeError: Converting circular structure to JSON"
//
// The fix adds sanitizeForDb() to strip circular references before DB insert.
const configPath = path.join(FIXTURES_DIR, 'configs/circular-ref-7266.yaml');
const outputPath = path.join(OUTPUT_DIR, 'circular-ref-7266-output.json');
const { exitCode, stderr, stdout } = runCli(
['eval', '-c', configPath, '-o', outputPath, '--no-cache'],
{ cwd: FIXTURES_DIR },
);
// On main (before fix): exitCode would be non-zero or stderr would contain
// "Converting circular structure to JSON" or "Error saving result"
//
// With fix: exitCode should be 0 and results should be saved successfully
if (exitCode !== 0) {
console.error('stdout:', stdout);
console.error('stderr:', stderr);
}
expect(exitCode).toBe(0);
expect(stderr).not.toContain('Converting circular structure to JSON');
expect(stderr).not.toContain('Error saving result');
// Verify the output file was created and contains valid results
expect(fs.existsSync(outputPath)).toBe(true);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
// The test should have run successfully
expect(parsed.results.results[0].success).toBe(true);
expect(parsed.results.results[0].response.output).toContain('Processed');
// The metadata should exist (circular refs stripped, but normal data preserved)
expect(parsed.results.results[0].response.metadata).toBeDefined();
expect(parsed.results.results[0].response.metadata.normalData).toBe('this is fine');
});
});
});
describe('Provider Wrapper', () => {
describe('#7353 - class-based provider prototype id() method preservation', () => {
it('preserves id() method when using class-based providers in eval', () => {
// Bug #7353: When wrapProviderWithRateLimiting wraps a class-based provider,
// the spread operator doesn't copy prototype methods like id().
// This caused "TypeError: redteamProvider.id is not a function" in redteam
// strategies that call TokenUsageTracker.trackUsage(provider.id(), ...).
//
// The fix explicitly delegates id() to the original provider.
const configPath = path.join(FIXTURES_DIR, 'configs/class-provider-7353.yaml');
const outputPath = path.join(OUTPUT_DIR, 'class-provider-7353-output.json');
const { exitCode, stderr, stdout } = runCli(
['eval', '-c', configPath, '-o', outputPath, '--no-cache'],
{ cwd: path.join(FIXTURES_DIR, 'configs') },
);
if (exitCode !== 0) {
console.error('stdout:', stdout);
console.error('stderr:', stderr);
}
// Should not fail with "id is not a function"
expect(stderr).not.toContain('is not a function');
expect(exitCode).toBe(0);
// Verify the output file was created and contains valid results
expect(fs.existsSync(outputPath)).toBe(true);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
// The test should have run successfully with the class-based provider
expect(parsed.results.results[0].success).toBe(true);
// Verify the provider's id() method was accessible (included in output)
expect(parsed.results.results[0].response.output).toContain('ClassProvider');
});
it('preserves id() method when using class-based providers in redteam', () => {
// This tests the redteam generate flow with a class-based provider whose
// id() method is on the prototype. Uses the contracts plugin (local generation)
// and base64 strategy (local transform) so no remote API calls are needed.
// The provider returns "Prompt:"-formatted output so the plugin can parse it.
//
// Note: The wrapProviderWithRateLimiting code path (where the original
// #7353 bug manifested) is covered by the unit test in
// test/scheduler/providerWrapper.test.ts.
const configPath = path.join(FIXTURES_DIR, 'configs/redteam-class-provider-7353.yaml');
const { exitCode, stderr, stdout } = runCli(
['redteam', 'generate', '-c', configPath, '--no-cache'],
{
cwd: path.join(FIXTURES_DIR, 'configs'),
env: { PROMPTFOO_DISABLE_REMOTE_GENERATION: 'true' },
},
);
const output = stdout + stderr;
// The key assertion: should NOT fail with "id is not a function"
expect(output).not.toContain('is not a function');
expect(output).not.toContain('redteamProvider.id is not a function');
expect(output).not.toContain('TypeError');
// The command should succeed (contracts plugin generates locally)
if (exitCode !== 0) {
console.error('stdout:', stdout);
console.error('stderr:', stderr);
}
expect(exitCode).toBe(0);
});
});
});
describe('JSON Schema Validation', () => {
describe('#7096 - TestCase options and metadata schema validation', () => {
it('validates config with combined options from merged schemas', () => {
// Bug #7096: z.intersection() in Zod v4 generates allOf with additionalProperties:false
// which caused JSON Schema validators to reject valid options like 'provider' when
// combined with other options like 'prefix' from different sub-schemas.
const configPath = path.join(FIXTURES_DIR, 'configs/schema-validation-7096.yaml');
const outputPath = path.join(OUTPUT_DIR, 'schema-validation-7096-output.json');
const { exitCode, stderr } = runCli([
'eval',
'-c',
configPath,
'-o',
outputPath,
'--no-cache',
]);
// Should succeed - the config uses options.provider, options.prefix, options.transform
// which come from different merged schemas (GradingConfig, PromptConfig, OutputConfig)
expect(exitCode).toBe(0);
if (exitCode !== 0) {
console.error('stderr:', stderr);
}
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
// Verify the test ran successfully
expect(parsed.results.results[0].success).toBe(true);
// Verify metadata with custom keys was preserved
expect(parsed.results.results[0].testCase.metadata).toHaveProperty(
'customTag',
'math-test',
);
expect(parsed.results.results[0].testCase.metadata).toHaveProperty('experimentId', 12345);
});
it('generates valid JSON Schema without problematic allOf pattern for options', () => {
// This test validates the generated schema file doesn't have the broken pattern
const schemaPath = path.join(ROOT_DIR, 'site/static/config-schema.json');
// Verify schema file exists
expect(fs.existsSync(schemaPath)).toBe(true);
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
// The schema should exist and have definitions
expect(schema).toHaveProperty('definitions');
expect(schema).toHaveProperty('$schema');
// Find the TestCase options schema definition
// The fix changed it from nested z.intersection() (which creates allOf with multiple additionalProperties:false)
// to a flat z.object() with spread (which creates a single object with all properties)
const definitions = schema.definitions;
// Look for a definition that has the options properties (prefix, suffix, provider, etc.)
// and verify it's NOT using the problematic allOf pattern
let foundOptionsSchema = false;
for (const def of Object.values(definitions)) {
const defObj = def as Record<string, unknown>;
const props = defObj.properties as Record<string, unknown> | undefined;
// Check if this is the options schema (has prefix, provider, disableVarExpansion)
if (props && props.prefix && props.provider && props.disableVarExpansion) {
foundOptionsSchema = true;
// The fix should result in a flat object, NOT an allOf
// If there's an allOf, it should NOT have multiple sub-schemas with additionalProperties:false
if (defObj.allOf) {
const allOf = defObj.allOf as Array<Record<string, unknown>>;
const schemasWithAdditionalPropsFalse = allOf.filter(
(s) => s.additionalProperties === false,
);
// The problematic pattern had 4 schemas each with additionalProperties:false
expect(schemasWithAdditionalPropsFalse.length).toBeLessThan(2);
}
break;
}
}
expect(foundOptionsSchema).toBe(true);
});
});
});
});