dotprompt
Version:
Dotprompt: Executable GenAI Prompt Templates
784 lines (627 loc) • 23 kB
text/typescript
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
import * as Handlebars from 'handlebars';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Dotprompt } from './dotprompt';
import * as parse from './parse';
import type { JSONSchema, PromptMetadata, ToolDefinition } from './types';
describe('Dotprompt', () => {
describe('constructor', () => {
it('should initialize with default options', () => {
const dp = new Dotprompt();
expect(dp).toBeInstanceOf(Dotprompt);
});
it('should initialize with custom model configs', () => {
const modelConfigs = {
'gemini-1.5-pro': { temperature: 0.7 },
'gemini-2.0-flash': { top_p: 0.9 },
};
const dp = new Dotprompt({ modelConfigs });
expect(dp).toBeInstanceOf(Dotprompt);
});
it('should initialize with default model', () => {
const defaultModel = 'gemini-1.5-pro';
const dp = new Dotprompt({ defaultModel });
expect(dp).toBeInstanceOf(Dotprompt);
});
it('should initialize with custom helpers', () => {
const customHelper = (context: unknown) => `HELPER: ${context}`;
const dp = new Dotprompt({
helpers: {
customHelper,
},
});
const template = '{{customHelper "test"}}';
const compiled = Handlebars.compile(template, { noEscape: true });
const result = compiled({ customHelper });
expect(result).toBe('HELPER: test');
});
it('should initialize with custom partials', () => {
const registerPartialSpy = vi.spyOn(Handlebars, 'registerPartial');
const partials = {
header: 'This is a header',
footer: 'This is a footer',
};
new Dotprompt({ partials });
expect(registerPartialSpy).toHaveBeenCalledWith(
'header',
'This is a header'
);
expect(registerPartialSpy).toHaveBeenCalledWith(
'footer',
'This is a footer'
);
});
});
describe('defineHelper', () => {
it('should register a helper function', () => {
const dp = new Dotprompt();
const helperFn = (context: unknown) => `Helper: ${context}`;
const registerHelperSpy = vi.spyOn(Handlebars, 'registerHelper');
dp.defineHelper('testHelper', helperFn);
expect(registerHelperSpy).toHaveBeenCalledWith('testHelper', helperFn);
});
it('should return the Dotprompt instance for chaining', () => {
const dp = new Dotprompt();
const helperFn = (context: unknown) => `Helper: ${context}`;
const result = dp.defineHelper('testHelper', helperFn);
expect(result).toBe(dp);
});
});
describe('definePartial', () => {
it('should register a partial template', () => {
const dp = new Dotprompt();
const registerPartialSpy = vi.spyOn(Handlebars, 'registerPartial');
dp.definePartial('testPartial', '<div>Partial content</div>');
expect(registerPartialSpy).toHaveBeenCalledWith(
'testPartial',
'<div>Partial content</div>'
);
});
it('should return the Dotprompt instance for chaining', () => {
const dp = new Dotprompt();
const result = dp.definePartial(
'testPartial',
'<div>Partial content</div>'
);
expect(result).toBe(dp);
});
});
describe('defineTool', () => {
it('should register a tool definition', () => {
const dp = new Dotprompt();
const toolDef: ToolDefinition = {
name: 'testTool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
};
dp.defineTool(toolDef);
expect(dp).toBeInstanceOf(Dotprompt);
});
it('should return the Dotprompt instance for chaining', () => {
const dp = new Dotprompt();
const toolDef: ToolDefinition = {
name: 'testTool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
};
const result = dp.defineTool(toolDef);
expect(result).toBe(dp);
});
});
describe('parse', () => {
it('should call parseDocument with the source string', () => {
const parseDocumentMock = vi.spyOn(parse, 'parseDocument');
parseDocumentMock.mockReturnValue({
template: 'Template content',
model: 'gemini-1.5-pro',
});
const dp = new Dotprompt();
const result = dp.parse('Source template');
expect(parseDocumentMock).toHaveBeenCalledWith('Source template');
expect(result).toEqual({
template: 'Template content',
model: 'gemini-1.5-pro',
});
});
});
describe('compile and render', () => {
let dp: Dotprompt;
beforeEach(() => {
dp = new Dotprompt();
});
it('should compile a template string into a render function', async () => {
const templateSource = 'Hello {{input.name}}!';
const parseDocumentMock = vi.spyOn(parse, 'parseDocument');
parseDocumentMock.mockReturnValue({
template: templateSource,
});
const handlebarsCompileMock = vi.spyOn(Handlebars, 'compile');
const mockCompiledTemplate = vi.fn().mockReturnValue('Hello World!');
handlebarsCompileMock.mockReturnValue(mockCompiledTemplate);
const renderer = await dp.compile(templateSource);
expect(parseDocumentMock).toHaveBeenCalledWith(templateSource);
expect(handlebarsCompileMock).toHaveBeenCalledWith(
templateSource,
expect.anything()
);
// Test the rendered function.
const toMessagesMock = vi.spyOn(parse, 'toMessages');
toMessagesMock.mockReturnValue([
{ role: 'user', content: [{ text: 'Hello World!' }] },
]);
const result = await renderer({ input: { name: 'World' } });
expect(mockCompiledTemplate).toHaveBeenCalled();
expect(toMessagesMock).toHaveBeenCalledWith(
'Hello World!',
expect.anything()
);
expect(result.messages).toEqual([
{ role: 'user', content: [{ text: 'Hello World!' }] },
]);
});
it('should render a template with provided data', async () => {
const templateSource = 'Hello {{input.name}}!';
const toMessagesMock = vi.spyOn(parse, 'toMessages');
toMessagesMock.mockReturnValue([
{ role: 'user', content: [{ text: 'Hello World!' }] },
]);
const parseDocumentMock = vi.spyOn(parse, 'parseDocument');
parseDocumentMock.mockReturnValue({
template: templateSource,
});
const handlebarsCompileMock = vi.spyOn(Handlebars, 'compile');
const mockCompiledTemplate = vi.fn().mockReturnValue('Hello World!');
handlebarsCompileMock.mockReturnValue(mockCompiledTemplate);
const result = await dp.render(templateSource, {
input: { name: 'World' },
});
expect(parseDocumentMock).toHaveBeenCalledWith(templateSource);
expect(handlebarsCompileMock).toHaveBeenCalledWith(
templateSource,
expect.anything()
);
expect(mockCompiledTemplate).toHaveBeenCalled();
expect(toMessagesMock).toHaveBeenCalledWith(
'Hello World!',
expect.anything()
);
expect(result.messages).toEqual([
{ role: 'user', content: [{ text: 'Hello World!' }] },
]);
});
});
describe('identifyPartials', () => {
it('should identify partial references in a template', () => {
const dp = new Dotprompt();
const template =
'Start {{> header}} Middle {{> sidebar}} End {{> footer}}';
const ast = Handlebars.parse(template);
const partialSet = new Set<string>();
const originalHandlebarsVisitor = Handlebars.Visitor;
class MockVisitor extends originalHandlebarsVisitor {
PartialStatement(partial: unknown): void {
if (
partial &&
typeof partial === 'object' &&
'name' in partial &&
partial.name &&
typeof partial.name === 'object' &&
'original' in partial.name &&
typeof partial.name.original === 'string'
) {
partialSet.add(partial.name.original);
}
}
}
// @ts-ignore Temporarily modify Handlebars.Visitor
Handlebars.Visitor = MockVisitor;
const visitor = new Handlebars.Visitor();
visitor.accept(ast);
// @ts-ignore Restore Handlebars.Visitor
Handlebars.Visitor = originalHandlebarsVisitor;
expect(partialSet.has('header')).toBe(true);
expect(partialSet.has('sidebar')).toBe(true);
expect(partialSet.has('footer')).toBe(true);
expect(partialSet.size).toBe(3);
});
it('should return an empty set for a template with no partials', () => {
const dp = new Dotprompt();
const template = 'Template with {{variable}} but no partials';
const ast = Handlebars.parse(template);
const partialSet = new Set<string>();
const originalHandlebarsVisitor = Handlebars.Visitor;
class MockVisitor extends originalHandlebarsVisitor {
PartialStatement(partial: unknown): void {
if (
partial &&
typeof partial === 'object' &&
'name' in partial &&
partial.name &&
typeof partial.name === 'object' &&
'original' in partial.name &&
typeof partial.name.original === 'string'
) {
partialSet.add(partial.name.original);
}
}
}
// @ts-ignore Temporarily modify Handlebars.Visitor.
Handlebars.Visitor = MockVisitor;
const visitor = new Handlebars.Visitor();
visitor.accept(ast);
// @ts-ignore Restore Handlebars.Visitor.
Handlebars.Visitor = originalHandlebarsVisitor;
expect(partialSet.size).toBe(0);
});
});
describe('resolveTools', () => {
it('should resolve registered tools', async () => {
const dp = new Dotprompt();
const toolDef: ToolDefinition = {
name: 'testTool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
};
dp.defineTool(toolDef);
const metadata: PromptMetadata = {
tools: ['testTool', 'unknownTool'],
};
// @ts-ignore Accessing private method for testing
const result = await dp.resolveTools(metadata);
expect(result.toolDefs).toHaveLength(1);
expect(result.toolDefs?.[0]).toEqual(toolDef);
expect(result.tools).toEqual(['unknownTool']);
});
it('should use the tool resolver for unregistered tools', async () => {
const toolDef: ToolDefinition = {
name: 'resolvedTool',
description: 'A resolved tool',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
};
const toolResolver = vi.fn().mockResolvedValue(toolDef);
const dp = new Dotprompt({
toolResolver,
});
const metadata: PromptMetadata = {
tools: ['resolvedTool'],
};
// @ts-ignore Accessing private method for testing
const result = await dp.resolveTools(metadata);
expect(toolResolver).toHaveBeenCalledWith('resolvedTool');
expect(result.toolDefs).toHaveLength(1);
expect(result.toolDefs?.[0]).toEqual(toolDef);
expect(result.tools).toEqual([]);
});
it('should throw an error when the tool resolver returns null', async () => {
const toolResolver = vi.fn().mockResolvedValue(null);
const dp = new Dotprompt({
toolResolver,
});
const metadata: PromptMetadata = {
tools: ['nonExistentTool'],
};
// @ts-ignore Accessing private method for testing
await expect(dp.resolveTools(metadata)).rejects.toThrow(
"Dotprompt: Unable to resolve tool 'nonExistentTool' to a recognized tool definition."
);
});
});
describe('renderPicoschema', () => {
it('should process picoschema definitions', async () => {
const dp = new Dotprompt();
vi.mock('./picoschema', () => ({
picoschema: vi.fn().mockImplementation((schema) => {
return Promise.resolve({
type: 'object',
properties: { expanded: true },
});
}),
}));
const metadata: PromptMetadata = {
input: {
schema: { type: 'string' },
},
output: {
schema: { type: 'number' },
},
};
// @ts-ignore Accessing private method for testing.
const result = await dp.renderPicoschema(metadata);
expect(result.input?.schema).toEqual({
type: 'object',
properties: { expanded: true },
});
expect(result.output?.schema).toEqual({
type: 'object',
properties: { expanded: true },
});
});
it('should return the original metadata if no schemas are present', async () => {
const dp = new Dotprompt();
const metadata: PromptMetadata = {
model: 'gemini-1.5-pro',
};
// @ts-ignore Accessing private method for testing.
const result = await dp.renderPicoschema(metadata);
expect(result).toEqual(metadata);
});
});
describe('wrappedSchemaResolver', () => {
it('should resolve schemas from the registered schemas', async () => {
const schemas: Record<string, JSONSchema> = {
'test-schema': {
type: 'object',
properties: {
name: { type: 'string' },
},
},
};
const dp = new Dotprompt({ schemas });
// @ts-ignore Accessing private method for testing.
const result = await dp.wrappedSchemaResolver('test-schema');
expect(result).toEqual(schemas['test-schema']);
});
it('should use the schema resolver for unregistered schemas', async () => {
const schemaResolver = vi.fn().mockResolvedValue({
type: 'object',
properties: {
resolved: { type: 'boolean' },
},
});
const dp = new Dotprompt({ schemaResolver });
// @ts-ignore Accessing private method for testing.
const result = await dp.wrappedSchemaResolver('external-schema');
expect(schemaResolver).toHaveBeenCalledWith('external-schema');
expect(result).toEqual({
type: 'object',
properties: {
resolved: { type: 'boolean' },
},
});
});
it('should return null if schema not found and no resolver', async () => {
const dp = new Dotprompt();
// @ts-ignore Accessing private method for testing.
const result = await dp.wrappedSchemaResolver('non-existent-schema');
expect(result).toBeNull();
});
});
describe('resolveMetadata', () => {
it('should merge multiple metadata objects', async () => {
const dp = new Dotprompt();
const base: PromptMetadata = {
model: 'gemini-1.5-pro',
config: {
temperature: 0.7,
},
};
const merge1: PromptMetadata = {
config: {
top_p: 0.9,
},
tools: ['tool1'],
};
const merge2: PromptMetadata = {
model: 'gemini-2.0-flash',
config: {
max_tokens: 2000,
},
};
// @ts-ignore Mocking private method.
dp.resolveTools = vi
.fn()
.mockImplementation((metadata) => Promise.resolve(metadata));
// @ts-ignore Mocking private method.
dp.renderPicoschema = vi
.fn()
.mockImplementation((metadata) => Promise.resolve(metadata));
// @ts-ignore Accessing private method for testing.
const result = await dp.resolveMetadata(base, merge1, merge2);
expect(result.model).toBe('gemini-2.0-flash');
expect(result.config).toEqual({
temperature: 0.7,
top_p: 0.9,
max_tokens: 2000,
});
expect(result.tools).toEqual(['tool1']);
// @ts-ignore
expect(dp.resolveTools).toHaveBeenCalled();
// @ts-ignore
expect(dp.renderPicoschema).toHaveBeenCalled();
});
it('should handle undefined merges', async () => {
const dp = new Dotprompt();
const base: PromptMetadata = {
model: 'gemini-1.5-pro',
config: {
temperature: 0.7,
},
};
// @ts-ignore Mocking private method.
dp.resolveTools = vi
.fn()
.mockImplementation((metadata) => Promise.resolve(metadata));
// @ts-ignore Mocking private method.
dp.renderPicoschema = vi
.fn()
.mockImplementation((metadata) => Promise.resolve(metadata));
// @ts-ignore Accessing private method for testing.
const result = await dp.resolveMetadata(base, undefined);
expect(result.model).toBe('gemini-1.5-pro');
expect(result.config).toEqual({
temperature: 0.7,
});
});
});
describe('resolvePartials', () => {
it('should resolve and register partials from a template', async () => {
const partialResolver = vi.fn();
partialResolver.mockImplementation((name: string) => {
if (name === 'header') return 'Header content';
if (name === 'footer') return 'Footer content';
return null;
});
const dp = new Dotprompt({ partialResolver });
// @ts-ignore Creating a partials mock.
Handlebars.partials = {};
const identifyPartialsMock = vi
.fn()
.mockReturnValue(new Set(['header', 'footer']));
// @ts-ignore Setting private method for testing.
dp.identifyPartials = identifyPartialsMock;
const definePartialSpy = vi.spyOn(dp, 'definePartial');
const template = '{{> header}} Main content {{> footer}}';
// @ts-ignore Calling private method for testing.
await dp.resolvePartials(template);
expect(identifyPartialsMock).toHaveBeenCalledWith(template);
expect(partialResolver).toHaveBeenCalledWith('header');
expect(partialResolver).toHaveBeenCalledWith('footer');
expect(definePartialSpy).toHaveBeenCalledWith('header', 'Header content');
expect(definePartialSpy).toHaveBeenCalledWith('footer', 'Footer content');
});
it('should not try to resolve partials when no resolver or store is provided', async () => {
const dp = new Dotprompt();
const template = '{{> header}} Main content {{> footer}}';
const identifyPartialsMock = vi.fn();
// @ts-ignore Mocking private method.
dp.identifyPartials = identifyPartialsMock;
// @ts-ignore Accessing private method for testing.
await dp.resolvePartials(template);
expect(identifyPartialsMock).not.toHaveBeenCalled();
});
it('should use the store as fallback if partial resolver returns null', async () => {
const partialResolver = vi.fn().mockResolvedValue(null);
const store = {
loadPartial: vi.fn().mockResolvedValue({
source: 'Partial from store',
}),
};
const dp = new Dotprompt({ partialResolver });
// @ts-ignore Setting private property for testing.
dp.store = store;
// @ts-ignore Creating a partials mock.
Handlebars.partials = {};
const template = '{{> partial}}';
const identifyPartialsMock = vi
.fn()
.mockReturnValue(new Set(['partial']));
// @ts-ignore Mocking private method.
dp.identifyPartials = identifyPartialsMock;
const definePartialSpy = vi.spyOn(dp, 'definePartial');
// @ts-ignore Accessing private method for testing
await dp.resolvePartials(template);
expect(partialResolver).toHaveBeenCalledWith('partial');
expect(store.loadPartial).toHaveBeenCalledWith('partial');
expect(definePartialSpy).toHaveBeenCalledWith(
'partial',
'Partial from store'
);
});
});
describe('renderMetadata', () => {
it('should process parsed source metadata', async () => {
const dp = new Dotprompt();
const parsedSource = {
template: 'Template content',
model: 'gemini-1.5-pro',
};
const resolveMetadataMock = vi.fn().mockResolvedValue({
model: 'gemini-1.5-pro',
processed: true,
});
// @ts-ignore Mocking private method
dp.resolveMetadata = resolveMetadataMock;
const result = await dp.renderMetadata(parsedSource);
expect(resolveMetadataMock).toHaveBeenCalledWith(
{},
parsedSource,
undefined
);
expect(result).toEqual({
model: 'gemini-1.5-pro',
processed: true,
});
});
it('should use the default model when no model is specified', async () => {
const dp = new Dotprompt({ defaultModel: 'default-model' });
const parsedSource = {
template: 'Template content',
};
const resolveMetadataMock = vi.fn().mockResolvedValue({
model: 'default-model',
processed: true,
});
// @ts-ignore Mocking private method
dp.resolveMetadata = resolveMetadataMock;
const result = await dp.renderMetadata(parsedSource);
expect(resolveMetadataMock).toHaveBeenCalled();
expect(result).toEqual({
model: 'default-model',
processed: true,
});
});
it('should use model configs when available', async () => {
const modelConfigs = {
'gemini-1.5-pro': { temperature: 0.7 },
};
const dp = new Dotprompt({ modelConfigs });
const parsedSource = {
template: 'Template content',
model: 'gemini-1.5-pro',
};
const resolveMetadataMock = vi
.fn()
.mockImplementation((base, ...args) => {
return Promise.resolve({
...args[0],
config: { ...base.config },
});
});
// @ts-ignore Mocking private method
dp.resolveMetadata = resolveMetadataMock;
const result = await dp.renderMetadata(parsedSource);
expect(resolveMetadataMock).toHaveBeenCalledWith(
{ config: { temperature: 0.7 } },
parsedSource,
undefined
);
expect(result.config).toEqual({ temperature: 0.7 });
});
});
});