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
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

374 lines
12 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { beforeAll, describe, expect, it } from 'vitest';
describe('config-schema.json', () => {
let schema: any;
let ajv: Ajv;
// Helper to resolve $ref references in the schema
const resolveRef = (obj: any): any => {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (obj.$ref && typeof obj.$ref === 'string') {
// Parse $ref like "#/definitions/__schema32"
const refPath = obj.$ref.replace('#/', '').split('/');
let resolved = schema;
for (const part of refPath) {
resolved = resolved?.[part];
}
return resolved;
}
return obj;
};
beforeAll(() => {
// Read the schema file
const schemaPath = path.join(__dirname, '..', 'site', 'static', 'config-schema.json');
const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
schema = JSON.parse(schemaContent);
// Initialize AJV for schema validation
// Use strict: false to avoid issues with regex patterns
ajv = new Ajv({
strict: false,
allErrors: true,
validateFormats: false, // Disable format validation to avoid regex issues
});
addFormats(ajv);
});
it('should be valid JSON', () => {
expect(schema).toBeDefined();
expect(typeof schema).toBe('object');
});
it('should be a valid JSON Schema', () => {
const valid = ajv.validateSchema(schema);
expect(valid, `Schema validation errors: ${JSON.stringify(ajv.errors, null, 2)}`).toBe(true);
});
it('should have required top-level properties', () => {
expect(schema).toHaveProperty('$ref');
expect(schema).toHaveProperty('definitions');
expect(schema.definitions).toHaveProperty('PromptfooConfigSchema');
});
describe('redteam plugin enums', () => {
it('should not have duplicate entries in plugin enums', () => {
const findPluginEnums = (
obj: any,
path: string = '',
): Array<{ path: string; values: string[] }> => {
const results: Array<{ path: string; values: string[] }> = [];
if (obj && typeof obj === 'object') {
// Check if this is an enum array that looks like a plugin list
if (Array.isArray(obj.enum) && obj.enum.length > 10 && obj.enum.includes('bias')) {
results.push({ path, values: obj.enum });
}
for (const [key, value] of Object.entries(obj)) {
if (key !== 'enum') {
results.push(...findPluginEnums(value, path ? `${path}.${key}` : key));
}
}
}
return results;
};
const pluginEnums = findPluginEnums(schema);
expect(pluginEnums.length).toBeGreaterThan(0);
pluginEnums.forEach(({ path, values }) => {
const uniqueValues = [...new Set(values)];
const duplicates = values.filter((item, index) => values.indexOf(item) !== index);
expect(values, `Duplicates found at ${path}: ${duplicates.join(', ')}`).toHaveLength(
uniqueValues.length,
);
});
});
it('should have consistent plugin lists across different locations', () => {
const findAllEnums = (obj: any): string[][] => {
const results: string[][] = [];
if (obj && typeof obj === 'object') {
if (Array.isArray(obj.enum) && obj.enum.length > 10 && obj.enum.includes('bias')) {
results.push(obj.enum);
}
for (const value of Object.values(obj)) {
results.push(...findAllEnums(value));
}
}
return results;
};
const allEnums = findAllEnums(schema);
// Should find at least 2 (one for string type, one for object id)
expect(allEnums.length).toBeGreaterThanOrEqual(2);
const firstEnum = allEnums[0]?.sort();
expect(firstEnum).toBeDefined();
for (let i = 1; i < allEnums.length; i++) {
expect(allEnums[i].sort()).toEqual(firstEnum);
}
});
it('should contain expected plugin entries', () => {
const findPluginEnum = (obj: any): string[] | null => {
if (obj && typeof obj === 'object') {
if (Array.isArray(obj.enum) && obj.enum.includes('bias')) {
return obj.enum;
}
for (const value of Object.values(obj)) {
const result = findPluginEnum(value);
if (result) {
return result;
}
}
}
return null;
};
const pluginEnum = findPluginEnum(schema);
expect(pluginEnum).not.toBeNull();
// Use non-null assertion since we've already checked it's not null
const plugins = pluginEnum!;
expect(plugins).toContain('bias');
expect(plugins).toContain('bias:age');
expect(plugins).toContain('bias:disability');
expect(plugins).toContain('bias:gender');
expect(plugins).toContain('bias:race');
expect(plugins).toContain('default');
expect(plugins).toContain('harmful');
expect(plugins).toContain('pii');
const biasCount = plugins.filter((p) => p === 'bias').length;
expect(biasCount).toBe(1);
});
});
describe('schema structure', () => {
it('should define UnifiedConfig properly', () => {
const unifiedConfig = schema.definitions?.PromptfooConfigSchema;
expect(unifiedConfig).toBeDefined();
expect(unifiedConfig.type).toBe('object');
expect(unifiedConfig.properties).toBeDefined();
});
it('should have redteam configuration', () => {
const properties = schema.definitions?.PromptfooConfigSchema?.properties;
expect(properties).toHaveProperty('redteam');
// Resolve $ref if the property is a reference
const redteamConfig = resolveRef(properties?.redteam);
expect(redteamConfig).toBeDefined();
expect(redteamConfig.type).toBe('object');
expect(redteamConfig.properties).toHaveProperty('plugins');
expect(redteamConfig.properties).toHaveProperty('strategies');
});
it('should validate that plugin patterns are properly escaped', () => {
const findPatterns = (obj: any): string[] => {
const patterns: string[] = [];
if (obj && typeof obj === 'object') {
if (typeof obj.pattern === 'string') {
patterns.push(obj.pattern);
}
for (const value of Object.values(obj)) {
patterns.push(...findPatterns(value));
}
}
return patterns;
};
const patterns = findPatterns(schema);
const filePatterns = patterns.filter((p) => p.includes('file'));
expect(filePatterns.length).toBeGreaterThan(0);
filePatterns.forEach((pattern) => {
expect(pattern).toMatch(/file.*\\/);
});
});
});
describe('performance', () => {
it('should not be excessively large', () => {
const schemaSize = JSON.stringify(schema).length;
// Schema should be under 1MB
expect(schemaSize).toBeLessThan(1024 * 1024);
});
});
describe('generated schema integrity', () => {
it('should match the structure generated by zod-to-json-schema', () => {
// Verify the schema follows zod-to-json-schema conventions
expect(schema.$ref).toBe('#/definitions/PromptfooConfigSchema');
expect(schema.definitions).toBeDefined();
// Check that the main schema definition exists
const mainDef = schema.definitions.PromptfooConfigSchema;
expect(mainDef).toBeDefined();
expect(mainDef.type).toBe('object');
});
it('should regenerate to the same schema', async () => {
// This test ensures the schema generation is deterministic
// We'll check that key structures are present
const properties = schema.definitions?.PromptfooConfigSchema?.properties;
// Core properties that should always exist
expect(properties).toHaveProperty('prompts');
expect(properties).toHaveProperty('providers');
expect(properties).toHaveProperty('tests');
expect(properties).toHaveProperty('redteam');
expect(properties).toHaveProperty('scenarios');
expect(properties).toHaveProperty('defaultTest');
});
});
describe('transform schema', () => {
it('should accept string transforms in JSON/YAML config files', () => {
const validate = ajv.compile(schema);
const config = {
prompts: ['hello'],
providers: ['echo'],
tests: [
{
options: {
transform: 'output.trim()',
transformVars: '{ ...vars, name: vars.name.toUpperCase() }',
},
assert: [
{
type: 'contains',
value: 'HELLO',
transform: 'output.toUpperCase()',
contextTransform: 'output.context',
},
],
},
],
};
expect(validate(config)).toBe(true);
});
it('should reject non-string transforms in JSON/YAML config files', () => {
const validate = ajv.compile(schema);
const config = {
prompts: ['hello'],
providers: ['echo'],
tests: [
{
options: {
transform: { unexpected: 'object' },
},
},
],
};
expect(validate(config)).toBe(false);
expect(validate.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
instancePath: '/tests/0/options/transform',
keyword: 'type',
}),
]),
);
});
it('should reject non-string redteam provider transforms in JSON/YAML config files', () => {
const validate = ajv.compile(schema);
const config = {
prompts: ['hello'],
redteam: {
plugins: ['default'],
strategies: ['default'],
provider: {
id: 'openai:chat:gpt-4o-mini',
transform: { unexpected: 'object' },
},
},
};
expect(validate(config)).toBe(false);
expect(validate.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
instancePath: '/redteam/provider/transform',
keyword: 'type',
}),
]),
);
});
it('emits string-only types for every transform field in the JSON schema', () => {
// Guard against the StringOrFunctionSchema leaking into the generated
// config-schema.json via a `z.custom` → `{}` (any) branch. Every transform
// property anywhere in the schema tree must declare `type: string` so
// external YAML/JSON validators reject objects, numbers, and functions.
const schemaJson = JSON.stringify(schema);
const transformOccurrences = schemaJson.match(/"transform":\s*\{[^}]*\}/g) ?? [];
const transformVarsOccurrences = schemaJson.match(/"transformVars":\s*\{[^}]*\}/g) ?? [];
const contextTransformOccurrences =
schemaJson.match(/"contextTransform":\s*\{[^}]*\}/g) ?? [];
const postprocessOccurrences = schemaJson.match(/"postprocess":\s*\{[^}]*\}/g) ?? [];
const allTransformFields = [
...transformOccurrences,
...transformVarsOccurrences,
...contextTransformOccurrences,
...postprocessOccurrences,
];
expect(allTransformFields.length).toBeGreaterThan(0);
for (const field of allTransformFields) {
expect(field).toContain('"type":"string"');
}
});
});
describe('per-test repeat schema', () => {
it('accepts positive integers and rejects invalid repeat counts', () => {
const validate = ajv.compile(schema);
const config = {
prompts: ['hello'],
providers: ['echo'],
tests: [{ options: { repeat: 3 } }],
};
expect(validate(config)).toBe(true);
for (const repeat of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
config.tests[0].options.repeat = repeat;
expect(validate(config), `repeat ${repeat} should be invalid`).toBe(false);
}
});
});
});