Files
promptfoo--promptfoo/test/providers/openai/chatkit.test.ts
T
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

508 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { disableCache, enableCache } from '../../../src/cache';
import {
cleanAssistantResponse,
OpenAiChatKitProvider,
} from '../../../src/providers/openai/chatkit';
import { mockProcessEnv } from '../../util/utils';
// Mock Playwright - we don't want to actually launch browsers in unit tests
vi.mock('playwright', () => ({
chromium: {
launch: vi.fn().mockResolvedValue({
newContext: vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(undefined),
waitForFunction: vi.fn().mockResolvedValue(undefined),
waitForTimeout: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(undefined),
reload: vi.fn().mockResolvedValue(undefined),
frames: vi.fn().mockReturnValue([]),
on: vi.fn(), // Console event listener
}),
close: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
},
}));
// Mock http server
vi.mock('http', () => ({
createServer: vi.fn().mockReturnValue({
listen: vi.fn((_port: number, callback: () => void) => callback()),
address: vi.fn().mockReturnValue({ port: 3000 }),
close: vi.fn(),
once: vi.fn(), // For error event handler
}),
}));
// Helper to access the generated HTML (we test via the provider's internal HTML generation)
function getGeneratedHTML(provider: OpenAiChatKitProvider): string {
// Access private method via casting
const config = (provider as any).chatKitConfig;
const apiKey = 'test-key';
const workflowId = config.workflowId;
const version = config.version;
const userId = config.userId;
// Generate HTML the same way the provider does internally
const versionClause = version ? `, version: '${version}'` : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ChatKit Eval</title>
</head>
<body>
<openai-chatkit id="chatkit"></openai-chatkit>
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js"></script>
<script>
window.__state = { ready: false, responses: [], threadId: null, error: null, responding: false };
async function init() {
const chatkit = document.getElementById('chatkit');
// Wait for element to be ready
let attempts = 0;
while (typeof chatkit.setOptions !== 'function' && attempts < 100) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
if (typeof chatkit.setOptions !== 'function') {
window.__state.error = 'ChatKit component failed to initialize';
return;
}
let cachedSecret = null;
chatkit.setOptions({
api: {
getClientSecret: async (existing) => {
if (existing) return existing;
if (cachedSecret) return cachedSecret;
const res = await fetch('https://api.openai.com/v1/chatkit/sessions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ${apiKey}',
'Content-Type': 'application/json',
'OpenAI-Beta': 'chatkit_beta=v1',
'X-OpenAI-Originator': 'promptfoo'
},
body: JSON.stringify({
workflow: { id: '${workflowId}'${versionClause} },
user: '${userId}'
})
});
if (!res.ok) {
const text = await res.text();
throw new Error('Session failed: ' + res.status + ' ' + text);
}
const data = await res.json();
cachedSecret = data.client_secret;
return cachedSecret;
}
},
header: { enabled: false },
history: { enabled: false },
});
chatkit.addEventListener('chatkit.ready', () => {
window.__state.ready = true;
});
chatkit.addEventListener('chatkit.error', (e) => {
window.__state.error = e.detail.error?.message || 'Unknown error';
});
chatkit.addEventListener('chatkit.thread.change', (e) => {
window.__state.threadId = e.detail.threadId;
});
chatkit.addEventListener('chatkit.response.start', () => {
window.__state.responding = true;
});
chatkit.addEventListener('chatkit.response.end', () => {
window.__state.responding = false;
window.__state.responses.push({ timestamp: Date.now() });
});
window.__chatkit = chatkit;
}
init().catch(e => {
window.__state.error = e.message;
});
</script>
</body>
</html>`;
}
describe('OpenAiChatKitProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
disableCache();
});
afterEach(() => {
// Use clearAllMocks instead of resetAllMocks to preserve mock implementations
vi.clearAllMocks();
enableCache();
});
describe('constructor', () => {
it('should create provider with workflow ID', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: { apiKey: 'test-key' },
});
expect(provider.id()).toBe('openai:chatkit:wf_test123');
});
it('should use workflowId from config if provided', () => {
const provider = new OpenAiChatKitProvider('default', {
config: {
apiKey: 'test-key',
workflowId: 'wf_custom',
},
});
expect(provider.id()).toBe('openai:chatkit:wf_custom');
});
it('should include version in id() when provided', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: {
apiKey: 'test-key',
version: '3',
},
});
expect(provider.id()).toBe('openai:chatkit:wf_test123:3');
});
it('should use default timeout of 120000ms', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
// Access private config via any
expect((provider as any).chatKitConfig.timeout).toBe(120000);
});
it('should use custom timeout when provided', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
timeout: 60000,
},
});
expect((provider as any).chatKitConfig.timeout).toBe(60000);
});
it('should default headless to true', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
expect((provider as any).chatKitConfig.headless).toBe(true);
});
it('should allow headless to be set to false', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
headless: false,
},
});
expect((provider as any).chatKitConfig.headless).toBe(false);
});
});
describe('toString', () => {
it('should return descriptive string', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: { apiKey: 'test-key' },
});
expect(provider.toString()).toBe('[OpenAI ChatKit Provider wf_test123]');
});
});
describe('callApi', () => {
it('should return error when API key is missing', async () => {
const restoreEnv = mockProcessEnv({ OPENAI_API_KEY: undefined });
try {
const provider = new OpenAiChatKitProvider('wf_test', {});
const result = await provider.callApi('test prompt');
expect(result.error).toContain('API key');
} finally {
restoreEnv();
}
});
});
describe('cleanup', () => {
it('should close browser resources', async () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
// Initialize first (will use mocked playwright)
await (provider as any).initialize();
// Then cleanup
await provider.cleanup();
// Verify state is reset
expect((provider as any).browser).toBeNull();
expect((provider as any).context).toBeNull();
expect((provider as any).page).toBeNull();
expect((provider as any).server).toBeNull();
expect((provider as any).initialized).toBe(false);
});
it('should handle cleanup when not initialized', async () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
// Should not throw when cleaning up uninitialized provider
await expect(provider.cleanup()).resolves.toBeUndefined();
});
});
describe('HTML template generation', () => {
it('should include responding state tracking in window.__state', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: { apiKey: 'test-key' },
});
const html = getGeneratedHTML(provider);
// Verify the responding state is included
expect(html).toContain('responding: false');
expect(html).toContain('window.__state.responding = true');
expect(html).toContain('window.__state.responding = false');
});
it('should have chatkit.response.start event listener', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: { apiKey: 'test-key' },
});
const html = getGeneratedHTML(provider);
// Verify the response.start listener is included for multi-step workflow tracking
expect(html).toContain("chatkit.addEventListener('chatkit.response.start'");
});
it('should have chatkit.response.end event listener', () => {
const provider = new OpenAiChatKitProvider('wf_test123', {
config: { apiKey: 'test-key' },
});
const html = getGeneratedHTML(provider);
// Verify the response.end listener is included
expect(html).toContain("chatkit.addEventListener('chatkit.response.end'");
});
});
describe('configuration validation', () => {
it('should reject invalid workflowId format', () => {
// The validateWorkflowId function in the source code rejects invalid formats
// This gets triggered during HTML generation, not during construction
const provider = new OpenAiChatKitProvider('invalid-workflow', {
config: { apiKey: 'test-key' },
});
// The validation happens when HTML is generated, which we can test via the helper
// Invalid workflow ID should not match the expected pattern
expect((provider as any).chatKitConfig.workflowId).toBe('invalid-workflow');
});
it('should accept valid workflowId format', () => {
const provider = new OpenAiChatKitProvider('wf_abc123XYZ', {
config: { apiKey: 'test-key' },
});
expect((provider as any).chatKitConfig.workflowId).toBe('wf_abc123XYZ');
});
it('should handle empty workflowId', () => {
const provider = new OpenAiChatKitProvider('', {
config: { apiKey: 'test-key', workflowId: 'wf_fromconfig' },
});
// Should use workflowId from config when empty string passed
expect((provider as any).chatKitConfig.workflowId).toBe('wf_fromconfig');
});
it('should handle special characters in userId', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
userId: 'user@example.com',
},
});
// userId with @ should be accepted
expect((provider as any).chatKitConfig.userId).toBe('user@example.com');
});
it('should handle version with dots and dashes', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
version: '1.2.3-beta',
},
});
expect((provider as any).chatKitConfig.version).toBe('1.2.3-beta');
});
it('should use consistent default userId across instances', () => {
const provider1 = new OpenAiChatKitProvider('wf_test1', {
config: { apiKey: 'test-key' },
});
const provider2 = new OpenAiChatKitProvider('wf_test2', {
config: { apiKey: 'test-key' },
});
// Both should have the same default userId for template consistency
expect((provider1 as any).chatKitConfig.userId).toBe((provider2 as any).chatKitConfig.userId);
});
});
describe('cleanAssistantResponse', () => {
it('should return empty string for empty input', () => {
expect(cleanAssistantResponse('')).toBe('');
});
it('should return empty string for null/undefined input', () => {
expect(cleanAssistantResponse(null as any)).toBe('');
expect(cleanAssistantResponse(undefined as any)).toBe('');
});
it('should remove Cloudflare scripts', () => {
const input = 'Hello world(function(){console.log("cf")})();';
expect(cleanAssistantResponse(input)).toBe('Hello world');
});
it('should remove approval UI text from end of response', () => {
const input =
'Here is your response\nApproval required\nDoes this work for you?\nApprove\nReject';
expect(cleanAssistantResponse(input)).toBe('Here is your response');
});
it('should remove approval UI text with varying whitespace', () => {
const input =
'Here is your response\n\nApproval required\n\nDoes this work for you?\n\nApprove\n\nReject';
expect(cleanAssistantResponse(input)).toBe('Here is your response');
});
it('should return empty string when response starts with "You said:"', () => {
const input = 'You said: hello world';
expect(cleanAssistantResponse(input)).toBe('');
});
it('should remove "You said:" and everything after it', () => {
const input = 'Assistant response here\nYou said: some user input';
expect(cleanAssistantResponse(input)).toBe('Assistant response here');
});
it('should strip JSON prefix when followed by substantial text', () => {
const jsonPrefix = '{"classification": "return_item"}';
const substantialText =
'I can help you with your return request. Please provide your order number and reason for the return.';
const input = `${jsonPrefix} ${substantialText}`;
expect(cleanAssistantResponse(input)).toBe(substantialText);
});
it('should preserve JSON when it is the only response', () => {
const input = '{"classification": "return_item"}';
expect(cleanAssistantResponse(input)).toBe('{"classification": "return_item"}');
});
it('should preserve JSON when followed by short text (< 50 chars)', () => {
const input = '{"action": "query"} Short response';
expect(cleanAssistantResponse(input)).toBe('{"action": "query"} Short response');
});
it('should handle complex nested cleanup scenarios', () => {
const input =
'(function(){})();Here is your response\nApproval required\nDoes this work for you?\nApprove\nReject';
expect(cleanAssistantResponse(input)).toBe('Here is your response');
});
it('should trim whitespace from result', () => {
const input = ' Hello world ';
expect(cleanAssistantResponse(input)).toBe('Hello world');
});
});
describe('approval handling configuration', () => {
it('should default approvalHandling to auto-approve', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
expect((provider as any).chatKitConfig.approvalHandling).toBe('auto-approve');
});
it('should allow approvalHandling to be set to auto-reject', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
approvalHandling: 'auto-reject',
},
});
expect((provider as any).chatKitConfig.approvalHandling).toBe('auto-reject');
});
it('should allow approvalHandling to be set to skip', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
approvalHandling: 'skip',
},
});
expect((provider as any).chatKitConfig.approvalHandling).toBe('skip');
});
it('should default maxApprovals to 5', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: { apiKey: 'test-key' },
});
expect((provider as any).chatKitConfig.maxApprovals).toBe(5);
});
it('should allow custom maxApprovals', () => {
const provider = new OpenAiChatKitProvider('wf_test', {
config: {
apiKey: 'test-key',
maxApprovals: 10,
},
});
expect((provider as any).chatKitConfig.maxApprovals).toBe(10);
});
});
});