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
609 lines
24 KiB
TypeScript
609 lines
24 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { importModule } from '../../src/esm';
|
|
import logger from '../../src/logger';
|
|
import { runPython } from '../../src/python/pythonUtils';
|
|
import {
|
|
FILE_TRANSFORM_LABEL,
|
|
getTransformErrorMessage,
|
|
getTransformLabel,
|
|
INLINE_FUNCTION_LABEL,
|
|
INLINE_STRING_LABEL,
|
|
TransformInputType,
|
|
transform,
|
|
} from '../../src/util/transform';
|
|
|
|
import type { TransformFunction } from '../../src/types/transform';
|
|
|
|
vi.mock('../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
getDirectory: vi.fn().mockReturnValue('/test/dir'),
|
|
}));
|
|
|
|
vi.mock('../../src/logger', () => ({
|
|
default: {
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/python/pythonUtils', () => ({
|
|
runPython: vi.fn().mockImplementation(async (_filePath, _functionName, args) => {
|
|
const [output] = args;
|
|
return output.toUpperCase() + ' FROM PYTHON';
|
|
}),
|
|
}));
|
|
|
|
vi.mock('fs', () => ({
|
|
unlink: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('glob', () => ({
|
|
globSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/database', () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
|
|
describe('util', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('transform', () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('transforms output using a direct function', async () => {
|
|
const output = 'original output';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const transformFunction = 'output.toUpperCase()';
|
|
const transformedOutput = await transform(transformFunction, output, context);
|
|
expect(transformedOutput).toBe('ORIGINAL OUTPUT');
|
|
});
|
|
|
|
it('transforms vars using a direct function', async () => {
|
|
const vars = { key: 'value' };
|
|
const context = { vars: {}, prompt: { id: '123' } };
|
|
const transformFunction = 'JSON.stringify(vars)';
|
|
const transformedOutput = await transform(
|
|
transformFunction,
|
|
vars,
|
|
context,
|
|
true,
|
|
TransformInputType.VARS,
|
|
);
|
|
expect(transformedOutput).toBe('{"key":"value"}');
|
|
});
|
|
|
|
it('transforms output using an imported function from a file', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
|
|
|
const transformFunctionPath = 'file://transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO');
|
|
});
|
|
|
|
it('transforms vars using a direct function from a file', async () => {
|
|
const vars = { key: 'value' };
|
|
const context = { vars: {}, prompt: {} };
|
|
mockedImportModule.mockResolvedValueOnce((vars: any) => ({
|
|
...vars,
|
|
key: 'transformed',
|
|
}));
|
|
const transformFunctionPath = 'file://transform.js';
|
|
const transformedOutput = await transform(
|
|
transformFunctionPath,
|
|
vars,
|
|
context,
|
|
true,
|
|
TransformInputType.VARS,
|
|
);
|
|
expect(transformedOutput).toEqual({ key: 'transformed' });
|
|
});
|
|
|
|
it('throws error if transform function does not return a value', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = ''; // Empty function, returns undefined
|
|
await expect(transform(transformFunction, output, context)).rejects.toThrow(
|
|
'Transform function did not return a value',
|
|
);
|
|
});
|
|
|
|
it('throws error if file does not export a function', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
mockedImportModule.mockResolvedValueOnce('banana');
|
|
const transformFunctionPath = 'file://transform.js';
|
|
await expect(transform(transformFunctionPath, output, context)).rejects.toThrow(
|
|
'Transform transform.js must export a function, have a default export as a function, or export the specified function "undefined"',
|
|
);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error loading transform function from file',
|
|
expect.objectContaining({
|
|
transform: FILE_TRANSFORM_LABEL,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('transforms output using a Python file', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const pythonFilePath = 'file://transform.py';
|
|
|
|
const transformedOutput = await transform(pythonFilePath, output, context);
|
|
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
|
});
|
|
|
|
it('throws error for unsupported file format', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const unsupportedFilePath = 'file://transform.txt';
|
|
|
|
await expect(transform(unsupportedFilePath, output, context)).rejects.toThrow(
|
|
'Unsupported transform file format: file://transform.txt',
|
|
);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error loading transform function from file',
|
|
expect.objectContaining({
|
|
message: 'Unsupported transform file format: file://transform.txt',
|
|
transform: FILE_TRANSFORM_LABEL,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('transforms output using a multi-line function', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const multiLineFunction = `
|
|
const uppercased = output.toUpperCase();
|
|
return uppercased + ' WORLD';
|
|
`;
|
|
|
|
const transformedOutput = await transform(multiLineFunction, output, context);
|
|
expect(transformedOutput).toBe('HELLO WORLD');
|
|
});
|
|
|
|
it('transforms output using a default export function from a file', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
mockedImportModule.mockResolvedValueOnce(
|
|
(output: string) => output.toUpperCase() + ' DEFAULT',
|
|
);
|
|
|
|
const transformFunctionPath = 'file://transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO DEFAULT');
|
|
});
|
|
|
|
it('transforms output using a named function from a JavaScript file', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
// When a function name is specified, importModule returns that specific function
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase() + ' NAMED');
|
|
|
|
const transformFunctionPath = 'file://transform.js:namedFunction';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO NAMED');
|
|
});
|
|
|
|
it('transforms output using a named function from a Python file', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const pythonFilePath = 'file://transform.py:custom_transform';
|
|
|
|
const transformedOutput = await transform(pythonFilePath, output, context);
|
|
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('transform.py'),
|
|
'custom_transform',
|
|
[output, expect.any(Object)],
|
|
);
|
|
});
|
|
|
|
it('falls back to get_transform for Python files when no function name is provided', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const pythonFilePath = 'file://transform.py';
|
|
|
|
const transformedOutput = await transform(pythonFilePath, output, context);
|
|
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('transform.py'),
|
|
'get_transform',
|
|
[output, expect.any(Object)],
|
|
);
|
|
});
|
|
|
|
it('does not throw error when validateReturn is false and function returns undefined', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = ''; // Empty function, returns undefined
|
|
const result = await transform(transformFunction, output, context, false);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('throws error when validateReturn is true and function returns undefined', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = ''; // Empty function, returns undefined
|
|
await expect(transform(transformFunction, output, context, true)).rejects.toThrow(
|
|
'Transform function did not return a value',
|
|
);
|
|
});
|
|
|
|
it('does not throw error when validateReturn is false and function returns null', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = 'null'; // Will be wrapped with "return" automatically
|
|
const result = await transform(transformFunction, output, context, false);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('throws error when validateReturn is true and function returns null', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = 'null'; // Will be wrapped with "return" automatically
|
|
await expect(transform(transformFunction, output, context, true)).rejects.toThrow(
|
|
'Transform function did not return a value',
|
|
);
|
|
});
|
|
|
|
it('handles file transform function errors gracefully', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const errorMessage = 'File not found';
|
|
|
|
mockedImportModule.mockRejectedValueOnce(new Error(errorMessage));
|
|
|
|
const transformFunctionPath = 'file://transform.js';
|
|
await expect(transform(transformFunctionPath, output, context)).rejects.toThrow(errorMessage);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error loading transform function from file',
|
|
expect.objectContaining({
|
|
message: errorMessage,
|
|
transform: FILE_TRANSFORM_LABEL,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('handles inline transform function errors gracefully', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const invalidFunction = 'invalid javascript code {';
|
|
|
|
await expect(transform(invalidFunction, output, context)).rejects.toThrow(
|
|
'Unexpected identifier',
|
|
);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error creating inline transform function',
|
|
expect.objectContaining({
|
|
transform: expect.stringContaining(INLINE_STRING_LABEL),
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe('ESM compatibility', () => {
|
|
it('provides process.mainModule.require for backwards compatibility with CommonJS patterns', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
// This is the exact pattern users were using before ESM migration
|
|
// const require = process.mainModule.require;
|
|
const transformFunction = `
|
|
const require = process.mainModule.require;
|
|
const path = require('node:path');
|
|
return path.basename('/foo/bar/baz.txt');
|
|
`;
|
|
const result = await transform(transformFunction, output, context);
|
|
expect(result).toBe('baz.txt');
|
|
});
|
|
|
|
it('allows direct use of process.mainModule.require without assignment', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = `
|
|
const fs = process.mainModule.require('node:fs');
|
|
const os = process.mainModule.require('node:os');
|
|
return typeof fs.existsSync === 'function' && typeof os.homedir === 'function' ? output.toUpperCase() : output;
|
|
`;
|
|
const result = await transform(transformFunction, output, context);
|
|
expect(result).toBe('HELLO');
|
|
});
|
|
|
|
it('preserves other process properties like process.env', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const transformFunction = `
|
|
// process.env should still work
|
|
const env = process.env;
|
|
return typeof env === 'object' ? 'env-works' : 'env-broken';
|
|
`;
|
|
const result = await transform(transformFunction, output, context);
|
|
expect(result).toBe('env-works');
|
|
});
|
|
});
|
|
|
|
describe('file path handling', () => {
|
|
it('handles absolute paths in transform files', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
|
|
|
const transformFunctionPath = 'file://transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO');
|
|
});
|
|
|
|
it('handles file URLs in transform files', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
|
|
|
const transformFunctionPath = 'file://transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO');
|
|
});
|
|
|
|
it('handles Python files with absolute paths', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const pythonFilePath = 'file://transform.py';
|
|
|
|
const transformedOutput = await transform(pythonFilePath, output, context);
|
|
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('transform.py'),
|
|
'get_transform',
|
|
[output, expect.any(Object)],
|
|
);
|
|
});
|
|
|
|
it('handles complex nested paths', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
|
|
|
const transformFunctionPath = 'file://deeply/nested/path/with spaces/transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO');
|
|
});
|
|
|
|
it('handles paths with special characters', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
|
|
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
|
|
|
const transformFunctionPath = 'file://path/with-hyphens/and_underscores/transform.js';
|
|
const transformedOutput = await transform(transformFunctionPath, output, context);
|
|
expect(transformedOutput).toBe('HELLO');
|
|
});
|
|
});
|
|
|
|
describe('inline function transforms (Node.js package)', () => {
|
|
it('transforms output using a directly passed function', async () => {
|
|
const output = 'hello world';
|
|
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
|
const fn: TransformFunction = (output) => String(output).toUpperCase();
|
|
|
|
const result = await transform(fn, output, context);
|
|
expect(result).toBe('HELLO WORLD');
|
|
});
|
|
|
|
it('transforms output using an async function', async () => {
|
|
const output = 'hello';
|
|
const context = { vars: {}, prompt: {} };
|
|
const fn: TransformFunction = async (output) => {
|
|
return output + ' async';
|
|
};
|
|
|
|
const result = await transform(fn, output, context);
|
|
expect(result).toBe('hello async');
|
|
});
|
|
|
|
it('passes context to the function', async () => {
|
|
const output = 'test';
|
|
const context = { vars: { name: 'Alice' }, prompt: { label: 'greeting' } };
|
|
const fn: TransformFunction = (output, ctx: any) => `${output} for ${ctx.vars.name}`;
|
|
|
|
const result = await transform(fn, output, context);
|
|
expect(result).toBe('test for Alice');
|
|
});
|
|
|
|
it('transforms vars using a directly passed function', async () => {
|
|
const vars = { key: 'value', other: 'data' };
|
|
const context = { vars: {}, prompt: {} };
|
|
const fn: TransformFunction = (vars: any) => ({ ...vars, key: vars.key.toUpperCase() });
|
|
|
|
const result = await transform(fn, vars, context, true, TransformInputType.VARS);
|
|
expect(result).toEqual({ key: 'VALUE', other: 'data' });
|
|
});
|
|
|
|
it('throws when inline function returns undefined and validateReturn is true', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const fn: TransformFunction = () => undefined as any;
|
|
|
|
await expect(transform(fn, output, context, true)).rejects.toThrow(
|
|
'Transform function did not return a value',
|
|
);
|
|
});
|
|
|
|
it('does not throw when inline function returns undefined and validateReturn is false', async () => {
|
|
const output = 'test';
|
|
const context = { vars: {}, prompt: {} };
|
|
const fn: TransformFunction = () => undefined as any;
|
|
|
|
const result = await transform(fn, output, context, false);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('handles function that accesses metadata from context', async () => {
|
|
const output = 'raw output';
|
|
const context = {
|
|
vars: {},
|
|
prompt: {},
|
|
metadata: { toolCalls: [{ name: 'search' }, { name: 'calculate' }] },
|
|
};
|
|
const fn: TransformFunction = (_output, ctx: any) => {
|
|
const tools = ctx.metadata?.toolCalls ?? [];
|
|
return tools.map((t: any) => t.name).join(', ');
|
|
};
|
|
|
|
const result = await transform(fn, output, context);
|
|
expect(result).toBe('search, calculate');
|
|
});
|
|
|
|
it('propagates errors thrown by inline functions', async () => {
|
|
const fn: TransformFunction = () => {
|
|
throw new Error('user transform error');
|
|
};
|
|
|
|
await expect(transform(fn, 'test', { vars: {}, prompt: {} })).rejects.toThrow(
|
|
'user transform error',
|
|
);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error in transform function',
|
|
expect.objectContaining({
|
|
message: 'user transform error',
|
|
transform: expect.stringContaining(INLINE_FUNCTION_LABEL),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('wraps thrown errors with the transform label and preserves the original via cause', async () => {
|
|
const original = new Error('raw user error');
|
|
const fn: TransformFunction = () => {
|
|
throw original;
|
|
};
|
|
|
|
try {
|
|
await transform(fn, 'test', { vars: {}, prompt: {} });
|
|
throw new Error('expected transform to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(Error);
|
|
const e = err as Error & { cause?: unknown };
|
|
// Wrapped message includes the label AND the original error text.
|
|
expect(e.message).toContain('Transform failed (');
|
|
expect(e.message).toContain(INLINE_FUNCTION_LABEL);
|
|
expect(e.message).toContain('raw user error');
|
|
// The original error is attached via the standard `cause` chain.
|
|
expect(e.cause).toBe(original);
|
|
}
|
|
});
|
|
|
|
it('resolves thenables returned by inline functions', async () => {
|
|
// Bare thenable built via Object.defineProperty to dodge the `noThenProperty`
|
|
// lint rule; verifies `transform()` awaits anything Promise-like.
|
|
const thenable = Object.defineProperty({}, 'then', {
|
|
value: (resolve: (value: unknown) => void) => resolve('thenable-resolved'),
|
|
enumerable: true,
|
|
}) as unknown as Promise<string>;
|
|
const fn: TransformFunction = () => thenable;
|
|
|
|
const result = await transform(fn, 'ignored', { vars: {}, prompt: {} });
|
|
expect(result).toBe('thenable-resolved');
|
|
});
|
|
});
|
|
|
|
describe('getTransformErrorMessage', () => {
|
|
it('returns the raw message from a transform-wrapped Error via .cause', () => {
|
|
const raw = new Error('underlying boom');
|
|
const wrapped = new Error('Transform failed ([inline function]: fn): underlying boom');
|
|
(wrapped as Error & { cause?: unknown }).cause = raw;
|
|
expect(getTransformErrorMessage(wrapped)).toBe('underlying boom');
|
|
});
|
|
|
|
it('falls back to the error message when there is no cause', () => {
|
|
expect(getTransformErrorMessage(new Error('plain'))).toBe('plain');
|
|
});
|
|
|
|
it('stringifies non-Error throw values', () => {
|
|
expect(getTransformErrorMessage('just a string')).toBe('just a string');
|
|
expect(getTransformErrorMessage(42)).toBe('42');
|
|
});
|
|
|
|
it('returns the outer message when the cause is not an Error', () => {
|
|
const wrapped = new Error('outer');
|
|
(wrapped as Error & { cause?: unknown }).cause = 'a string cause';
|
|
expect(getTransformErrorMessage(wrapped)).toBe('outer');
|
|
});
|
|
});
|
|
|
|
describe('getTransformLabel', () => {
|
|
it('shows inline string transforms verbatim', () => {
|
|
expect(getTransformLabel('output.toUpperCase()')).toBe(
|
|
`${INLINE_STRING_LABEL}: output.toUpperCase()`,
|
|
);
|
|
});
|
|
|
|
it('collapses whitespace in multi-line inline strings', () => {
|
|
expect(getTransformLabel('const x = output;\n return x.trim();')).toBe(
|
|
`${INLINE_STRING_LABEL}: const x = output; return x.trim();`,
|
|
);
|
|
});
|
|
|
|
it('truncates long inline string transforms', () => {
|
|
const longExpr = 'output.' + 'a'.repeat(200);
|
|
const label = getTransformLabel(longExpr);
|
|
expect(label.startsWith(`${INLINE_STRING_LABEL}: `)).toBe(true);
|
|
expect(label.endsWith('…')).toBe(true);
|
|
expect(label.length).toBeLessThan(longExpr.length);
|
|
});
|
|
|
|
it('does not truncate an inline string exactly at the max length', () => {
|
|
const exprAtLimit = 'a'.repeat(80);
|
|
expect(getTransformLabel(exprAtLimit)).toBe(`${INLINE_STRING_LABEL}: ${exprAtLimit}`);
|
|
});
|
|
|
|
it('truncates an inline string one character over the max length', () => {
|
|
const exprOverLimit = 'a'.repeat(81);
|
|
const label = getTransformLabel(exprOverLimit);
|
|
// Label body is 79 chars + ellipsis → total 80 chars after the prefix.
|
|
expect(label).toBe(`${INLINE_STRING_LABEL}: ${'a'.repeat(79)}…`);
|
|
});
|
|
|
|
it('redacts file transform paths', () => {
|
|
expect(getTransformLabel('file://transform.js')).toBe(FILE_TRANSFORM_LABEL);
|
|
});
|
|
|
|
it('does not render function source in error labels', async () => {
|
|
const secretFn = function SECRET_SHOULD_NOT_APPEAR_IN_LABEL() {
|
|
return undefined;
|
|
};
|
|
await expect(transform(secretFn as any, 'test', { vars: {}, prompt: {} })).rejects.toThrow(
|
|
`Transform function did not return a value\n\n${INLINE_FUNCTION_LABEL}: SECRET_SHOULD_NOT_APPEAR_IN_LABEL`,
|
|
);
|
|
});
|
|
|
|
it('returns [inline function] for anonymous functions', () => {
|
|
expect(getTransformLabel(() => 'x')).toBe(INLINE_FUNCTION_LABEL);
|
|
});
|
|
|
|
it('includes function name for named functions', () => {
|
|
function myTransform() {
|
|
return 'x';
|
|
}
|
|
expect(getTransformLabel(myTransform)).toBe(`${INLINE_FUNCTION_LABEL}: myTransform`);
|
|
});
|
|
|
|
it('falls back to inline label for null or undefined inputs', () => {
|
|
expect(getTransformLabel(undefined)).toBe(INLINE_STRING_LABEL);
|
|
expect(getTransformLabel(null)).toBe(INLINE_STRING_LABEL);
|
|
});
|
|
});
|
|
});
|
|
});
|